Merge branch 'main' into issue/80-deshacerse-de-todos-estos-query-params-de-la-url

This commit is contained in:
2026-02-06 10:31:58 -06:00
24 changed files with 1138 additions and 554 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ count.txt
.nitro .nitro
.tanstack .tanstack
.wrangler .wrangler
diff.txt

View File

@@ -1,7 +1,7 @@
import type { import { useEffect, useState } from 'react'
NewSubjectWizardState,
TipoAsignatura, import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
} from '@/features/asignaturas/nueva/types' import type { Database } from '@/types/supabase'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -12,10 +12,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { import { useSubjectEstructuras } from '@/data'
ESTRUCTURAS_SEP, import { TIPOS_MATERIA } from '@/features/asignaturas/nueva/catalogs'
TIPOS_MATERIA, import { cn } from '@/lib/utils'
} from '@/features/asignaturas/nueva/catalogs'
export function PasoBasicosForm({ export function PasoBasicosForm({
wizard, wizard,
@@ -24,6 +23,20 @@ export function PasoBasicosForm({
wizard: NewSubjectWizardState wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>> onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) { }) {
const { data: estructuras } = useSubjectEstructuras()
const [creditosInput, setCreditosInput] = useState<string>(() => {
const c = Number(wizard.datosBasicos.creditos ?? 0)
return c > 0 ? c.toFixed(2) : ''
})
const [creditosFocused, setCreditosFocused] = useState(false)
useEffect(() => {
if (creditosFocused) return
const c = Number(wizard.datosBasicos.creditos ?? 0)
setCreditosInput(c > 0 ? c.toFixed(2) : '')
}, [wizard.datosBasicos.creditos, creditosFocused])
return ( return (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2"> <div className="grid gap-1 sm:col-span-2">
@@ -33,45 +46,66 @@ export function PasoBasicosForm({
placeholder="Ej. Matemáticas Discretas" placeholder="Ej. Matemáticas Discretas"
value={wizard.datosBasicos.nombre} value={wizard.datosBasicos.nombre}
onChange={(e) => onChange={(e) =>
onChange((w) => ({ onChange(
...w, (w): NewSubjectWizardState => ({
datosBasicos: { ...w.datosBasicos, nombre: e.target.value }, ...w,
})) datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
}),
)
} }
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/> />
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="clave">Clave (Opcional)</Label> <Label htmlFor="codigo">
Código
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input <Input
id="clave" id="codigo"
placeholder="Ej. MAT-101" placeholder="Ej. MAT-101"
value={wizard.datosBasicos.clave || ''} value={wizard.datosBasicos.codigo || ''}
onChange={(e) => onChange={(e) =>
onChange((w) => ({ onChange(
...w, (w): NewSubjectWizardState => ({
datosBasicos: { ...w.datosBasicos, clave: e.target.value }, ...w,
})) datosBasicos: { ...w.datosBasicos, codigo: e.target.value },
}),
)
} }
className="placeholder:text-muted-foreground/70 placeholder:italicplaceholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/> />
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="tipo">Tipo</Label> <Label htmlFor="tipo">Tipo</Label>
<Select <Select
value={wizard.datosBasicos.tipo} value={(wizard.datosBasicos.tipo ?? '') as string}
onValueChange={(val) => onValueChange={(value: string) =>
onChange((w) => ({ onChange(
...w, (w): NewSubjectWizardState => ({
datosBasicos: { ...w.datosBasicos, tipo: val as TipoAsignatura }, ...w,
})) datosBasicos: {
...w.datosBasicos,
tipo: value as NewSubjectWizardState['datosBasicos']['tipo'],
},
}),
)
} }
> >
<SelectTrigger <SelectTrigger
id="tipo" id="tipo"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!" className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.tipo
? 'text-muted-foreground font-normal italic opacity-70'
: 'font-medium not-italic',
)}
> >
<SelectValue /> <SelectValue placeholder="Ej. Obligatoria" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{TIPOS_MATERIA.map((t) => ( {TIPOS_MATERIA.map((t) => (
@@ -87,49 +121,175 @@ export function PasoBasicosForm({
<Label htmlFor="creditos">Créditos</Label> <Label htmlFor="creditos">Créditos</Label>
<Input <Input
id="creditos" id="creditos"
type="number" type="text"
min={0} inputMode="decimal"
value={wizard.datosBasicos.creditos} pattern="^\\d*(?:[.,]\\d{0,2})?$"
onChange={(e) => value={creditosInput}
onKeyDown={(e) => {
if (['-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onFocus={() => setCreditosFocused(true)}
onBlur={() => {
setCreditosFocused(false)
const raw = creditosInput.trim()
if (!raw) {
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
const normalized = raw.replace(',', '.')
const asNumber = Number.parseFloat(normalized)
if (!Number.isFinite(asNumber) || asNumber <= 0) {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
const fixed = asNumber.toFixed(2)
setCreditosInput(fixed)
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: Number(fixed) },
}))
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const nextRaw = e.target.value
if (nextRaw === '') {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
if (!/^\d*(?:[.,]\d{0,2})?$/.test(nextRaw)) return
setCreditosInput(nextRaw)
const asNumber = Number.parseFloat(nextRaw.replace(',', '.'))
onChange((w) => ({ onChange((w) => ({
...w, ...w,
datosBasicos: { datosBasicos: {
...w.datosBasicos, ...w.datosBasicos,
creditos: Number(e.target.value || 0), creditos:
Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 0,
}, },
})) }))
} }}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 4.50"
/> />
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="horas">Horas / Semana</Label> <Label htmlFor="horasAcademicas">
Horas Académicas
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input <Input
id="horas" id="horasAcademicas"
type="number" type="number"
min={0} min={1}
value={wizard.datosBasicos.horasSemana || 0} step={1}
onChange={(e) => inputMode="numeric"
onChange((w) => ({ pattern="[0-9]*"
...w, value={wizard.datosBasicos.horasAcademicas ?? ''}
datosBasicos: { onKeyDown={(e) => {
...w.datosBasicos, if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
horasSemana: Number(e.target.value || 0), e.preventDefault()
}, }
})) }}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasAcademicas: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
return n >= 1 ? n : 1
})(),
},
}),
)
} }
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 48"
/> />
</div> </div>
<div className="grid gap-1 sm:col-span-2"> <div className="grid gap-1">
<Label htmlFor="horasIndependientes">
Horas Independientes
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="horasIndependientes"
type="number"
min={1}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.horasIndependientes ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasIndependientes: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
return n >= 1 ? n : 1
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 24"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="estructura">Estructura de la asignatura</Label> <Label htmlFor="estructura">Estructura de la asignatura</Label>
<Select <Select
value={wizard.datosBasicos.estructuraId} value={wizard.datosBasicos.estructuraId as string}
onValueChange={(val) => onValueChange={(val) =>
onChange((w) => ({ onChange(
...w, (w): NewSubjectWizardState => ({
datosBasicos: { ...w.datosBasicos, estructuraId: val }, ...w,
})) datosBasicos: { ...w.datosBasicos, estructuraId: val },
}),
)
} }
> >
<SelectTrigger <SelectTrigger
@@ -139,11 +299,15 @@ export function PasoBasicosForm({
<SelectValue placeholder="Selecciona plantilla..." /> <SelectValue placeholder="Selecciona plantilla..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ESTRUCTURAS_SEP.map((e) => ( {estructuras?.map(
<SelectItem key={e.id} value={e.id}> (
{e.label} e: Database['public']['Tables']['estructuras_asignatura']['Row'],
</SelectItem> ) => (
))} <SelectItem key={e.id} value={e.id}>
{e.nombre}
</SelectItem>
),
)}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">

View File

@@ -1,11 +1,11 @@
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { Button } from '@/components/ui/button' import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { import {
Card, Card,
CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
@@ -21,22 +21,21 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
ARCHIVOS_SISTEMA_MOCK,
FACULTADES, FACULTADES,
MATERIAS_MOCK, MATERIAS_MOCK,
PLANES_MOCK, PLANES_MOCK,
} from '@/features/asignaturas/nueva/catalogs' } from '@/features/asignaturas/nueva/catalogs'
export function PasoConfiguracionPanel({ export function PasoDetallesPanel({
wizard, wizard,
onChange, onChange,
onGenerarIA, onGenerarIA: _onGenerarIA,
}: { }: {
wizard: NewSubjectWizardState wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>> onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
onGenerarIA: () => void onGenerarIA: () => void
}) { }) {
if (wizard.modoCreacion === 'MANUAL') { if (wizard.tipoOrigen === 'MANUAL') {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -50,116 +49,104 @@ export function PasoConfiguracionPanel({
) )
} }
if (wizard.modoCreacion === 'IA') { if (wizard.tipoOrigen === 'IA') {
return ( return (
<div className="grid gap-4"> <div className="flex flex-col gap-4">
<div className="grid gap-1"> <div className="flex flex-col gap-1">
<Label>Descripción del enfoque</Label> <Label>Descripción del enfoque académico</Label>
<Textarea <Textarea
placeholder="Ej. Asignatura teórica-práctica enfocada en patrones de diseño..." placeholder="Describe el enfoque, alcance y público objetivo. Ej.: Teórica-práctica enfocada en patrones de diseño, con proyectos semanales."
value={wizard.iaConfig?.descripcionEnfoque} value={wizard.iaConfig?.descripcionEnfoqueAcademico}
onChange={(e) => onChange={(e) =>
onChange((w) => ({ onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
descripcionEnfoqueAcademico: e.target.value,
},
}),
)
}
className="placeholder:text-muted-foreground/70 min-h-25 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="flex flex-col gap-1">
<Label>
Instrucciones adicionales para la IA
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Textarea
placeholder="Opcional: restricciones y preferencias. Ej.: incluye bibliografía en español, evita contenido avanzado, prioriza evaluación por proyectos."
value={wizard.iaConfig?.instruccionesAdicionalesIA}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
instruccionesAdicionalesIA: e.target.value,
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) =>
onChange((w): NewSubjectWizardState => {
const prev = w.iaConfig?.archivosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((a) => a !== id)
return {
...w, ...w,
iaConfig: { iaConfig: {
...w.iaConfig!, ...w.iaConfig!,
descripcionEnfoque: e.target.value, archivosReferencia: next,
}, },
})) }
} })
className="min-h-25" }
/> onToggleRepositorio={(id, checked) =>
</div> onChange((w): NewSubjectWizardState => {
<div className="grid gap-1"> const prev = w.iaConfig?.repositoriosReferencia || []
<Label>Notas adicionales</Label> const next = checked
<Textarea ? [...prev, id]
placeholder="Restricciones, bibliografía sugerida, etc." : prev.filter((r) => r !== id)
value={wizard.iaConfig?.notasAdicionales} return {
onChange={(e) =>
onChange((w) => ({
...w, ...w,
iaConfig: { ...w.iaConfig!, notasAdicionales: e.target.value }, iaConfig: {
})) ...w.iaConfig!,
} repositoriosReferencia: next,
/> },
</div> }
})
<div className="grid gap-2"> }
<Label>Archivos de contexto (Opcional)</Label> onFilesChange={(files: Array<UploadedFile>) =>
<div className="flex flex-col gap-2 rounded-md border p-3"> onChange(
{ARCHIVOS_SISTEMA_MOCK.map((file) => ( (w): NewSubjectWizardState => ({
<div key={file.id} className="flex items-center gap-2"> ...w,
<input iaConfig: {
type="checkbox" ...w.iaConfig!,
id={file.id} archivosAdjuntos: files,
checked={wizard.iaConfig?.archivosExistentesIds.includes( },
file.id, }),
)} )
onChange={(e) => { }
const checked = e.target.checked />
onChange((w) => ({
...w,
iaConfig: {
...w.iaConfig!,
archivosExistentesIds: checked
? [
...(w.iaConfig?.archivosExistentesIds || []),
file.id,
]
: w.iaConfig?.archivosExistentesIds.filter(
(id) => id !== file.id,
) || [],
},
}))
}}
/>
<Label htmlFor={file.id} className="font-normal">
{file.name}
</Label>
</div>
))}
</div>
</div>
<div className="flex justify-end">
<Button onClick={onGenerarIA} disabled={wizard.isLoading}>
{wizard.isLoading ? (
<>
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generando...
</>
) : (
<>
<Icons.Sparkles className="mr-2 h-4 w-4" /> Generar Preview
</>
)}
</Button>
</div>
{wizard.resumen.previewAsignatura && (
<Card className="bg-muted/50 border-dashed">
<CardHeader>
<CardTitle className="text-base">Vista previa generada</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
<p>
<strong>Objetivo:</strong>{' '}
{wizard.resumen.previewAsignatura.objetivo}
</p>
<p className="mt-2">
Se detectaron {wizard.resumen.previewAsignatura.unidades}{' '}
unidades temáticas y{' '}
{wizard.resumen.previewAsignatura.bibliografiaCount} fuentes
bibliográficas.
</p>
</CardContent>
</Card>
)}
</div> </div>
) )
} }
if (wizard.subModoClonado === 'INTERNO') { if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-3"> <div className="grid gap-2 sm:grid-cols-3">
@@ -217,12 +204,22 @@ export function PasoConfiguracionPanel({
{MATERIAS_MOCK.map((m) => ( {MATERIAS_MOCK.map((m) => (
<div <div
key={m.id} key={m.id}
role="button"
tabIndex={0}
onClick={() => onClick={() =>
onChange((w) => ({ onChange((w) => ({
...w, ...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id }, clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
})) }))
} }
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
}))
}}
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${ className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${
wizard.clonInterno?.asignaturaOrigenId === m.id wizard.clonInterno?.asignaturaOrigenId === m.id
? 'border-primary bg-primary/5 ring-primary ring-1' ? 'border-primary bg-primary/5 ring-primary ring-1'
@@ -245,7 +242,7 @@ export function PasoConfiguracionPanel({
) )
} }
if (wizard.subModoClonado === 'TRADICIONAL') { if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="rounded-lg border border-dashed p-8 text-center"> <div className="rounded-lg border border-dashed p-8 text-center">

View File

@@ -1,10 +1,6 @@
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import type { import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
ModoCreacion,
NewSubjectWizardState,
SubModoClonado,
} from '@/features/asignaturas/nueva/types'
import { import {
Card, Card,
@@ -21,19 +17,33 @@ export function PasoMetodoCardGroup({
wizard: NewSubjectWizardState wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>> onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) { }) {
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m const isSelected = (modo: NewSubjectWizardState['tipoOrigen']) =>
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s wizard.tipoOrigen === modo
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
const key = e.key
if (
key === 'Enter' ||
key === ' ' ||
key === 'Spacebar' ||
key === 'Space'
) {
e.preventDefault()
e.stopPropagation()
cb()
}
}
return ( return (
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<Card <Card
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''} className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
onClick={() => onClick={() =>
onChange((w) => ({ onChange(
...w, (w): NewSubjectWizardState => ({
modoCreacion: 'MANUAL', ...w,
subModoClonado: undefined, tipoOrigen: 'MANUAL',
})) }),
)
} }
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -51,11 +61,12 @@ export function PasoMetodoCardGroup({
<Card <Card
className={isSelected('IA') ? 'ring-ring ring-2' : ''} className={isSelected('IA') ? 'ring-ring ring-2' : ''}
onClick={() => onClick={() =>
onChange((w) => ({ onChange(
...w, (w): NewSubjectWizardState => ({
modoCreacion: 'IA', ...w,
subModoClonado: undefined, tipoOrigen: 'IA',
})) }),
)
} }
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -69,8 +80,10 @@ export function PasoMetodoCardGroup({
</Card> </Card>
<Card <Card
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''} className={isSelected('OTRO') ? 'ring-ring ring-2' : ''}
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))} onClick={() =>
onChange((w): NewSubjectWizardState => ({ ...w, tipoOrigen: 'OTRO' }))
}
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
@@ -80,51 +93,79 @@ export function PasoMetodoCardGroup({
</CardTitle> </CardTitle>
<CardDescription>De otra asignatura o archivo Word.</CardDescription> <CardDescription>De otra asignatura o archivo Word.</CardDescription>
</CardHeader> </CardHeader>
{wizard.modoCreacion === 'CLONADO' && ( {(wizard.tipoOrigen === 'OTRO' ||
<CardContent> wizard.tipoOrigen === 'CLONADO_INTERNO' ||
<div className="flex flex-col gap-3"> wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<div <CardContent className="flex flex-col gap-3">
role="button" <div
tabIndex={0} role="button"
onClick={(e) => { tabIndex={0}
e.stopPropagation() onClick={(e) => {
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })) e.stopPropagation()
}} onChange(
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${ (w): NewSubjectWizardState => ({
isSubSelected('INTERNO') ...w,
? 'bg-primary/5 text-primary ring-primary border-primary ring-1' tipoOrigen: 'CLONADO_INTERNO',
: 'border-border text-muted-foreground' }),
}`} )
> }}
<Icons.Database className="h-6 w-6 flex-none" /> onKeyDown={(e: React.KeyboardEvent) =>
<div className="flex flex-col"> handleKeyActivate(e, () =>
<span className="text-sm font-medium">Del sistema</span> onChange(
<span className="text-xs opacity-70"> (w): NewSubjectWizardState => ({
Buscar en otros planes ...w,
</span> tipoOrigen: 'CLONADO_INTERNO',
</div> }),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('CLONADO_INTERNO')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Database className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Del sistema</span>
<span className="text-xs opacity-70">
Buscar en otros planes
</span>
</div> </div>
</div>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })) onChange(
}} (w): NewSubjectWizardState => ({
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${ ...w,
isSubSelected('TRADICIONAL') tipoOrigen: 'CLONADO_TRADICIONAL',
? 'bg-primary/5 text-primary ring-primary border-primary ring-1' }),
: 'border-border text-muted-foreground' )
}`} }}
> onKeyDown={(e: React.KeyboardEvent) =>
<Icons.Upload className="h-6 w-6 flex-none" /> handleKeyActivate(e, () =>
<div className="flex flex-col"> onChange(
<span className="text-sm font-medium">Desde archivos</span> (w): NewSubjectWizardState => ({
<span className="text-xs opacity-70"> ...w,
Subir Word existente tipoOrigen: 'CLONADO_TRADICIONAL',
</span> }),
</div> ),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('CLONADO_TRADICIONAL')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Upload className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Desde archivos</span>
<span className="text-xs opacity-70">Subir Word existente</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -9,9 +9,38 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/nueva/catalogs' import { usePlan, useSubjectEstructuras } from '@/data'
import { formatFileSize } from '@/features/planes/utils/format-file-size'
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) { export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
const { data: plan } = usePlan(wizard.plan_estudio_id)
const { data: estructuras } = useSubjectEstructuras()
const estructuraNombre = (() => {
const estructuraId = wizard.datosBasicos.estructuraId
if (!estructuraId) return '—'
const hit = estructuras?.find((e) => e.id === estructuraId)
return hit?.nombre ?? estructuraId
})()
const modoLabel = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'Manual (Vacía)'
if (wizard.tipoOrigen === 'IA') return 'Generada con IA'
if (wizard.tipoOrigen === 'CLONADO_INTERNO') return 'Clonada (Sistema)'
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') return 'Clonada (Archivo)'
return '—'
})()
const creditosText =
typeof wizard.datosBasicos.creditos === 'number' &&
Number.isFinite(wizard.datosBasicos.creditos)
? wizard.datosBasicos.creditos.toFixed(2)
: '—'
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
const repositoriosRef = wizard.iaConfig?.repositoriosReferencia ?? []
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -20,53 +49,145 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
Verifica los datos antes de crear la asignatura. Verifica los datos antes de crear la asignatura.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 text-sm"> <CardContent>
<div className="grid grid-cols-2 gap-4"> <div className="grid gap-4 text-sm">
<div> <div className="grid gap-2">
<span className="text-muted-foreground">Nombre:</span> <div>
<div className="font-medium">{wizard.datosBasicos.nombre}</div> <span className="text-muted-foreground">Plan de estudios: </span>
<span className="font-medium">
{plan?.nombre || wizard.plan_estudio_id || '—'}
</span>
</div>
{plan?.carreras?.nombre ? (
<div>
<span className="text-muted-foreground">Carrera: </span>
<span className="font-medium">{plan.carreras.nombre}</span>
</div>
) : null}
</div> </div>
<div>
<span className="text-muted-foreground">Tipo:</span> <div className="bg-muted rounded-md p-3">
<div className="font-medium">{wizard.datosBasicos.tipo}</div> <span className="text-muted-foreground">Tipo de origen: </span>
<span className="inline-flex items-center gap-2 font-medium">
{wizard.tipoOrigen === 'MANUAL' && (
<Icons.Pencil className="h-4 w-4" />
)}
{wizard.tipoOrigen === 'IA' && (
<Icons.Sparkles className="h-4 w-4" />
)}
{(wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<Icons.Copy className="h-4 w-4" />
)}
{modoLabel}
</span>
</div> </div>
<div>
<span className="text-muted-foreground">Créditos:</span> <div className="grid grid-cols-2 gap-4">
<div className="font-medium">{wizard.datosBasicos.creditos}</div> <div className="col-span-2">
</div> <span className="text-muted-foreground">Nombre: </span>
<div> <span className="font-medium">
<span className="text-muted-foreground">Estructura:</span> {wizard.datosBasicos.nombre || '—'}
<div className="font-medium"> </span>
{ </div>
ESTRUCTURAS_SEP.find( <div>
(e) => e.id === wizard.datosBasicos.estructuraId, <span className="text-muted-foreground">Código: </span>
)?.label <span className="font-medium">
} {wizard.datosBasicos.codigo || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Tipo: </span>
<span className="font-medium">
{wizard.datosBasicos.tipo || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Créditos: </span>
<span className="font-medium">{creditosText}</span>
</div>
<div>
<span className="text-muted-foreground">Estructura: </span>
<span className="font-medium">{estructuraNombre}</span>
</div>
<div>
<span className="text-muted-foreground">Horas académicas: </span>
<span className="font-medium">
{wizard.datosBasicos.horasAcademicas ?? '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">
Horas independientes:{' '}
</span>
<span className="font-medium">
{wizard.datosBasicos.horasIndependientes ?? '—'}
</span>
</div> </div>
</div> </div>
</div>
<div className="bg-muted rounded-md p-3"> <div className="bg-muted/50 rounded-md p-3">
<span className="text-muted-foreground">Modo de creación:</span> <div className="font-medium">Configuración IA</div>
<div className="flex items-center gap-2 font-medium"> <div className="mt-2 grid gap-2">
{wizard.modoCreacion === 'MANUAL' && ( <div>
<> <span className="text-muted-foreground">
<Icons.Pencil className="h-4 w-4" /> Manual (Vacía) Enfoque académico:{' '}
</> </span>
)} <span className="font-medium">
{wizard.modoCreacion === 'IA' && ( {wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
<> </span>
<Icons.Sparkles className="h-4 w-4" /> Generada con IA </div>
</> <div>
)} <span className="text-muted-foreground">
{wizard.modoCreacion === 'CLONADO' && ( Instrucciones adicionales:{' '}
<> </span>
<Icons.Copy className="h-4 w-4" /> Clonada <span className="font-medium">
{wizard.subModoClonado === 'INTERNO' {wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
? ' (Sistema)' </span>
: ' (Archivo)'} </div>
</>
)} <div className="mt-2">
<div className="font-medium">Archivos de referencia</div>
{archivosRef.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{archivosRef.map((id) => (
<li key={id}>{id}</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
<div>
<div className="font-medium">Repositorios de referencia</div>
{repositoriosRef.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{repositoriosRef.map((id) => (
<li key={id}>{id}</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
<div>
<div className="font-medium">Archivos adjuntos</div>
{adjuntos.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => (
<li key={f.id}>
<span className="text-foreground">{f.file.name}</span>{' '}
<span>· {formatFileSize(f.file.size)}</span>
</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,64 +1,116 @@
import { useNavigate } from '@tanstack/react-router'
import type { AIGenerateSubjectInput } from '@/data'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useGenerateSubjectAI } from '@/data'
export function WizardControls({ export function WizardControls({
Wizard,
methods,
wizard, wizard,
canContinueDesdeMetodo, setWizard,
canContinueDesdeBasicos, errorMessage,
canContinueDesdeConfig, onPrev,
onCreate, onNext,
disablePrev,
disableNext,
disableCreate,
isLastStep,
}: { }: {
Wizard: any
methods: any
wizard: NewSubjectWizardState wizard: NewSubjectWizardState
canContinueDesdeMetodo: boolean setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
canContinueDesdeBasicos: boolean errorMessage?: string | null
canContinueDesdeConfig: boolean onPrev: () => void
onCreate: () => void onNext: () => void
disablePrev: boolean
disableNext: boolean
disableCreate: boolean
isLastStep: boolean
}) { }) {
const idx = Wizard.utils.getIndex(methods.current.id) const navigate = useNavigate()
const isLast = idx >= Wizard.steps.length - 1 const generateSubjectAI = useGenerateSubjectAI()
const handleCreate = async () => {
setWizard((w) => ({
...w,
isLoading: true,
errorMessage: null,
}))
try {
if (wizard.tipoOrigen === 'IA') {
const aiInput: AIGenerateSubjectInput = {
plan_estudio_id: wizard.plan_estudio_id,
datosBasicos: {
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo,
tipo: wizard.datosBasicos.tipo!,
creditos: wizard.datosBasicos.creditos!,
horasIndependientes: wizard.datosBasicos.horasIndependientes,
horasAcademicas: wizard.datosBasicos.horasAcademicas,
estructuraId: wizard.datosBasicos.estructuraId!,
},
iaConfig: {
descripcionEnfoqueAcademico:
wizard.iaConfig!.descripcionEnfoqueAcademico,
instruccionesAdicionalesIA:
wizard.iaConfig!.instruccionesAdicionalesIA,
archivosReferencia: wizard.iaConfig!.archivosReferencia,
repositoriosReferencia:
wizard.iaConfig!.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
},
}
console.log(
`${new Date().toISOString()} - Enviando a generar asignatura con IA`,
)
const asignatura = await generateSubjectAI.mutateAsync(aiInput)
console.log(
`${new Date().toISOString()} - Asignatura IA generada`,
asignatura,
)
navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
state: { showConfetti: true },
})
return
}
} catch (err: any) {
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura',
}))
} finally {
setWizard((w) => ({ ...w, isLoading: false }))
}
}
return ( return (
<div className="flex items-center justify-between"> <div className="flex grow items-center justify-between">
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior
</Button>
<div className="flex-1"> <div className="flex-1">
{wizard.errorMessage && ( {(errorMessage ?? wizard.errorMessage) && (
<span className="text-destructive text-sm font-medium"> <span className="text-destructive text-sm font-medium">
{wizard.errorMessage} {errorMessage ?? wizard.errorMessage}
</span> </span>
)} )}
</div> </div>
<div className="flex gap-4"> {isLastStep ? (
<Button <Button onClick={handleCreate} disabled={disableCreate}>
variant="secondary" {wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
onClick={() => methods.prev()}
disabled={idx === 0 || wizard.isLoading}
>
Anterior
</Button> </Button>
) : (
{!isLast ? ( <Button onClick={onNext} disabled={disableNext}>
<Button Siguiente
onClick={() => methods.next()} </Button>
disabled={ )}
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeConfig)
}
>
Siguiente
</Button>
) : (
<Button onClick={onCreate} disabled={wizard.isLoading}>
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
</Button>
)}
</div>
</div> </div>
) )
} }

View File

@@ -246,9 +246,9 @@ export function PasoBasicosForm({
// Keep undefined when the input is empty so the field stays optional // Keep undefined when the input is empty so the field stays optional
numCiclos: (() => { numCiclos: (() => {
const raw = e.target.value const raw = e.target.value
if (raw === '') return undefined if (raw === '') return null
const asNumber = Number(raw) const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return undefined if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero) // Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber)) const n = Math.floor(Math.abs(asNumber))
return n >= 1 ? n : 1 return n >= 1 ? n : 1

View File

@@ -90,7 +90,7 @@ export function PasoDetallesPanel({
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []} selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []} uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) => onToggleArchivo={(id, checked) =>
onChange((w) => { onChange((w): NewPlanWizardState => {
const prev = w.iaConfig?.archivosReferencia || [] const prev = w.iaConfig?.archivosReferencia || []
const next = checked const next = checked
? [...prev, id] ? [...prev, id]
@@ -105,7 +105,7 @@ export function PasoDetallesPanel({
}) })
} }
onToggleRepositorio={(id, checked) => onToggleRepositorio={(id, checked) =>
onChange((w) => { onChange((w): NewPlanWizardState => {
const prev = w.iaConfig?.repositoriosReferencia || [] const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked const next = checked
? [...prev, id] ? [...prev, id]

View File

@@ -1,5 +1,6 @@
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import type { AIGeneratePlanInput } from '@/data'
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain' import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
// import type { Database } from '@/types/supabase' // import type { Database } from '@/types/supabase'
@@ -54,11 +55,11 @@ export function WizardControls({
? wizard.datosBasicos.numCiclos ? wizard.datosBasicos.numCiclos
: 1 : 1
const aiInput = { const aiInput: AIGeneratePlanInput = {
datosBasicos: { datosBasicos: {
nombrePlan: wizard.datosBasicos.nombrePlan, nombrePlan: wizard.datosBasicos.nombrePlan,
carreraId: wizard.datosBasicos.carrera.id || undefined, carreraId: wizard.datosBasicos.carrera.id,
facultadId: wizard.datosBasicos.facultad.id || undefined, facultadId: wizard.datosBasicos.facultad.id,
nivel: wizard.datosBasicos.nivel as string, nivel: wizard.datosBasicos.nivel as string,
tipoCiclo: tipoCicloSafe, tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe, numCiclos: numCiclosSafe,
@@ -77,11 +78,11 @@ export function WizardControls({
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`) console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
const data = await generatePlanAI.mutateAsync(aiInput as any) const plan = await generatePlanAI.mutateAsync(aiInput as any)
console.log(`${new Date().toISOString()} - Plan IA generado`, data) console.log(`${new Date().toISOString()} - Plan IA generado`, plan)
navigate({ navigate({
to: `/planes/${data.plan.id}`, to: `/planes/${plan.id}`,
state: { showConfetti: true }, state: { showConfetti: true },
}) })
return return
@@ -122,7 +123,7 @@ export function WizardControls({
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}> <Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior Anterior
</Button> </Button>
<div className="flex-1"> <div className="mx-2 flex-1">
{errorMessage && ( {errorMessage && (
<span className="text-destructive text-sm font-medium"> <span className="text-destructive text-sm font-medium">
{errorMessage} {errorMessage}

View File

@@ -268,8 +268,8 @@ export type AIGeneratePlanInput = {
estructuraPlanId: UUID estructuraPlanId: UUID
} }
iaConfig: { iaConfig: {
descripcionEnfoque: string descripcionEnfoqueAcademico: string
notasAdicionales?: string instruccionesAdicionalesIA?: string
archivosReferencia?: Array<UUID> archivosReferencia?: Array<UUID>
repositoriosIds?: Array<UUID> repositoriosIds?: Array<UUID>
archivosAdjuntos: Array<UploadedFile> archivosAdjuntos: Array<UploadedFile>

View File

@@ -11,10 +11,12 @@ import type {
TipoAsignatura, TipoAsignatura,
UUID, UUID,
} from '../types/domain' } from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { Database } from '@/types/supabase'
const EDGE = { const EDGE = {
subjects_create_manual: 'subjects_create_manual', subjects_create_manual: 'subjects_create_manual',
ai_generate_subject: 'ai_generate_subject', ai_generate_subject: 'ai-generate-subject',
subjects_persist_from_ai: 'subjects_persist_from_ai', subjects_persist_from_ai: 'subjects_persist_from_ai',
subjects_clone_from_existing: 'subjects_clone_from_existing', subjects_clone_from_existing: 'subjects_clone_from_existing',
subjects_import_from_file: 'subjects_import_from_file', subjects_import_from_file: 'subjects_import_from_file',
@@ -101,26 +103,58 @@ export async function subjects_create_manual(
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload) return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload)
} }
export async function ai_generate_subject(payload: { export type AIGenerateSubjectInput = {
planId: UUID plan_estudio_id: Asignatura['plan_estudio_id']
datosBasicos: { datosBasicos: {
nombre: string nombre: Asignatura['nombre']
clave?: string codigo?: Asignatura['codigo']
tipo: TipoAsignatura tipo: Asignatura['tipo'] | null
creditos: number creditos: Asignatura['creditos'] | null
horasSemana?: number horasAcademicas?: Asignatura['horas_academicas'] | null
estructuraId: UUID horasIndependientes?: Asignatura['horas_independientes'] | null
estructuraId: Asignatura['estructura_id'] | null
} }
iaConfig: { // clonInterno?: {
descripcionEnfoque: string // facultadId?: string
notasAdicionales?: string // carreraId?: string
archivosExistentesIds?: Array<UUID> // planOrigenId?: string
repositoriosIds?: Array<UUID> // asignaturaOrigenId?: string | null
archivosAdhocIds?: Array<UUID> // }
usarMCP?: boolean // clonTradicional?: {
// archivoWordAsignaturaId: string | null
// archivosAdicionalesIds: Array<string>
// }
iaConfig?: {
descripcionEnfoqueAcademico: string
instruccionesAdicionalesIA: string
archivosReferencia: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
} }
}): Promise<any> { }
return invokeEdge<any>(EDGE.ai_generate_subject, payload)
export async function ai_generate_subject(
input: AIGenerateSubjectInput,
): Promise<any> {
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, index) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
})
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: {
@@ -227,6 +261,23 @@ export async function subjects_get_document(
}) })
} }
export async function subjects_get_structure_catalog(): Promise<
Array<Database['public']['Tables']['estructuras_asignatura']['Row']>
> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('estructuras_asignatura')
.select('*')
.order('nombre', { ascending: true })
if (error) {
throw error
}
return data
}
export async function asignaturas_update( export async function asignaturas_update(
asignaturaId: UUID, asignaturaId: UUID,
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias

View File

@@ -99,7 +99,7 @@ export function usePlanDocumento(planId: UUID | null | undefined) {
export function useCatalogosPlanes() { export function useCatalogosPlanes() {
return useQuery({ return useQuery({
queryKey: ['catalogos_planes'], queryKey: qk.estructurasPlan(),
queryFn: getCatalogos, queryFn: getCatalogos,
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian) staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
}) })

View File

@@ -11,6 +11,7 @@ import {
subjects_generate_document, subjects_generate_document,
subjects_get, subjects_get,
subjects_get_document, subjects_get_document,
subjects_get_structure_catalog,
subjects_history, subjects_history,
subjects_import_from_file, subjects_import_from_file,
subjects_persist_from_ai, subjects_persist_from_ai,
@@ -68,6 +69,13 @@ export function useSubjectDocumento(subjectId: UUID | null | undefined) {
}) })
} }
export function useSubjectEstructuras() {
return useQuery({
queryKey: qk.estructurasAsignatura(),
queryFn: () => subjects_get_structure_catalog(),
})
}
/* ------------------ Mutations ------------------ */ /* ------------------ Mutations ------------------ */
export function useCreateSubjectManual() { export function useCreateSubjectManual() {
@@ -89,7 +97,10 @@ export function useCreateSubjectManual() {
} }
export function useGenerateSubjectAI() { export function useGenerateSubjectAI() {
return useMutation({ mutationFn: ai_generate_subject }) const qc = useQueryClient()
return useMutation({
mutationFn: ai_generate_subject,
})
} }
export function usePersistSubjectFromAI() { export function usePersistSubjectFromAI() {

View File

@@ -1,12 +1,18 @@
import { supabaseBrowser } from "./client"; import {
FunctionsFetchError,
FunctionsHttpError,
FunctionsRelayError,
} from '@supabase/supabase-js'
import type { Database } from "@/types/supabase"; import { supabaseBrowser } from './client'
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from '@/types/supabase'
import type { SupabaseClient } from '@supabase/supabase-js'
export type EdgeInvokeOptions = { export type EdgeInvokeOptions = {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
headers?: Record<string, string>; headers?: Record<string, string>
}; }
export class EdgeFunctionError extends Error { export class EdgeFunctionError extends Error {
constructor( constructor(
@@ -15,8 +21,8 @@ export class EdgeFunctionError extends Error {
public readonly status?: number, public readonly status?: number,
public readonly details?: unknown, public readonly details?: unknown,
) { ) {
super(message); super(message)
this.name = "EdgeFunctionError"; this.name = 'EdgeFunctionError'
} }
} }
@@ -34,23 +40,69 @@ export async function invokeEdge<TOut>(
opts: EdgeInvokeOptions = {}, opts: EdgeInvokeOptions = {},
client?: SupabaseClient<Database>, client?: SupabaseClient<Database>,
): Promise<TOut> { ): Promise<TOut> {
const supabase = client ?? supabaseBrowser(); const supabase = client ?? supabaseBrowser()
const { data, error } = await supabase.functions.invoke(functionName, { const { data, error } = await supabase.functions.invoke(functionName, {
body, body,
method: opts.method ?? "POST", method: opts.method ?? 'POST',
headers: opts.headers, headers: opts.headers,
}); })
if (error) { if (error) {
const anyErr = error; // Valores por defecto (por si falla el parseo o es otro tipo de error)
throw new EdgeFunctionError( let message = error.message // El genérico "returned a non-2xx status code"
anyErr.message ?? "Error en Edge Function", let status = undefined
functionName, let details: unknown = error
anyErr.status,
anyErr, // 2. Verificamos si es un error HTTP (4xx o 5xx) que trae cuerpo JSON
); if (error instanceof FunctionsHttpError) {
try {
// Obtenemos el status real (ej. 404, 400)
status = error.context.status
// ¡LA CLAVE! Leemos el JSON que tu Edge Function envió
const errorBody = await error.context.json()
details = errorBody
// Intentamos extraer el mensaje humano según tu estructura { error: { message: "..." } }
// o la estructura simple { error: "..." }
if (errorBody && typeof errorBody === 'object') {
// Caso 1: Estructura anidada (la que definimos hace poco: { error: { message: "..." } })
if (
'error' in errorBody &&
typeof errorBody.error === 'object' &&
errorBody.error !== null &&
'message' in errorBody.error
) {
message = (errorBody.error as { message: string }).message
}
// Caso 2: Estructura simple ({ error: "Mensaje de error" })
else if (
'error' in errorBody &&
typeof errorBody.error === 'string'
) {
message = errorBody.error
}
// Caso 3: Propiedad message directa ({ message: "..." })
else if (
'message' in errorBody &&
typeof errorBody.message === 'string'
) {
message = errorBody.message
}
}
} catch (e) {
console.warn('No se pudo parsear el error JSON de la Edge Function', e)
}
} else if (error instanceof FunctionsRelayError) {
message = `Error de Relay Supabase: ${error.message}`
} else if (error instanceof FunctionsFetchError) {
message = `Error de conexión (Fetch): ${error.message}`
}
// 3. Lanzamos tu error personalizado con los datos reales extraídos
throw new EdgeFunctionError(message, functionName, status, details)
} }
return data as TOut; return data as TOut
} }

View File

@@ -4,7 +4,7 @@ import * as Icons from 'lucide-react'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard' import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm' import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm'
import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel' import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup' import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard' import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls' import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
@@ -54,7 +54,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
setWizard, setWizard,
canContinueDesdeMetodo, canContinueDesdeMetodo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeConfig, canContinueDesdeDetalles,
simularGeneracionIA, simularGeneracionIA,
crearAsignatura, crearAsignatura,
} = useNuevaAsignaturaWizard(planId) } = useNuevaAsignaturaWizard(planId)
@@ -104,13 +104,20 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
footerSlot={ footerSlot={
<Wizard.Stepper.Controls> <Wizard.Stepper.Controls>
<WizardControls <WizardControls
Wizard={Wizard} errorMessage={wizard.errorMessage}
methods={methods} onPrev={() => methods.prev()}
onNext={() => methods.next()}
disablePrev={idx === 0 || wizard.isLoading}
disableNext={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard} wizard={wizard}
canContinueDesdeMetodo={canContinueDesdeMetodo} setWizard={setWizard}
canContinueDesdeBasicos={canContinueDesdeBasicos}
canContinueDesdeConfig={canContinueDesdeConfig}
onCreate={() => crearAsignatura(handleClose)}
/> />
</Wizard.Stepper.Controls> </Wizard.Stepper.Controls>
} }
@@ -130,7 +137,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
{idx === 2 && ( {idx === 2 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoConfiguracionPanel <PasoDetallesPanel
wizard={wizard} wizard={wizard}
onChange={setWizard} onChange={setWizard}
onGenerarIA={simularGeneracionIA} onGenerarIA={simularGeneracionIA}

View File

@@ -1,19 +1,20 @@
import { useState } from "react"; import { useState } from 'react'
import type { AsignaturaPreview, NewSubjectWizardState } from "../types"; import type { AsignaturaPreview, NewSubjectWizardState } from '../types'
export function useNuevaAsignaturaWizard(planId: string) { export function useNuevaAsignaturaWizard(planId: string) {
const [wizard, setWizard] = useState<NewSubjectWizardState>({ const [wizard, setWizard] = useState<NewSubjectWizardState>({
step: 1, step: 1,
planId, plan_estudio_id: planId,
modoCreacion: null, tipoOrigen: null,
datosBasicos: { datosBasicos: {
nombre: "", nombre: '',
clave: "", codigo: '',
tipo: "OBLIGATORIA", tipo: null,
creditos: 0, creditos: null,
horasSemana: 0, horasAcademicas: null,
estructuraId: "", horasIndependientes: null,
estructuraId: '',
}, },
clonInterno: {}, clonInterno: {},
clonTradicional: { clonTradicional: {
@@ -21,42 +22,47 @@ export function useNuevaAsignaturaWizard(planId: string) {
archivosAdicionalesIds: [], archivosAdicionalesIds: [],
}, },
iaConfig: { iaConfig: {
descripcionEnfoque: "", descripcionEnfoqueAcademico: '',
notasAdicionales: "", instruccionesAdicionalesIA: '',
archivosExistentesIds: [], archivosReferencia: [],
repositoriosReferencia: [],
archivosAdjuntos: [],
}, },
resumen: {}, resumen: {},
isLoading: false, isLoading: false,
errorMessage: null, errorMessage: null,
}); })
const canContinueDesdeMetodo = wizard.modoCreacion === "MANUAL" || const canContinueDesdeMetodo =
wizard.modoCreacion === "IA" || wizard.tipoOrigen === 'MANUAL' ||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado); wizard.tipoOrigen === 'IA' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombre && const canContinueDesdeBasicos =
!!wizard.datosBasicos.nombre &&
wizard.datosBasicos.tipo !== null &&
wizard.datosBasicos.creditos !== null &&
wizard.datosBasicos.creditos > 0 && wizard.datosBasicos.creditos > 0 &&
!!wizard.datosBasicos.estructuraId; !!wizard.datosBasicos.estructuraId
const canContinueDesdeConfig = (() => { const canContinueDesdeDetalles = (() => {
if (wizard.modoCreacion === "MANUAL") return true; if (wizard.tipoOrigen === 'MANUAL') return true
if (wizard.modoCreacion === "IA") { if (wizard.tipoOrigen === 'IA') {
return !!wizard.iaConfig?.descripcionEnfoque; return !!wizard.iaConfig?.descripcionEnfoqueAcademico
} }
if (wizard.modoCreacion === "CLONADO") { if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
if (wizard.subModoClonado === "INTERNO") { return !!wizard.clonInterno?.asignaturaOrigenId
return !!wizard.clonInterno?.asignaturaOrigenId;
}
if (wizard.subModoClonado === "TRADICIONAL") {
return !!wizard.clonTradicional?.archivoWordAsignaturaId;
}
} }
return false; if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
})(); return !!wizard.clonTradicional?.archivoWordAsignaturaId
}
return false
})()
const simularGeneracionIA = async () => { const simularGeneracionIA = async () => {
setWizard((w) => ({ ...w, isLoading: true })); setWizard((w) => ({ ...w, isLoading: true }))
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500))
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
isLoading: false, isLoading: false,
@@ -64,27 +70,25 @@ export function useNuevaAsignaturaWizard(planId: string) {
previewAsignatura: { previewAsignatura: {
nombre: w.datosBasicos.nombre, nombre: w.datosBasicos.nombre,
objetivo: objetivo:
"Aplicar los fundamentos teóricos para la resolución de problemas...", 'Aplicar los fundamentos teóricos para la resolución de problemas...',
unidades: 5, unidades: 5,
bibliografiaCount: 3, bibliografiaCount: 3,
} as AsignaturaPreview, } as AsignaturaPreview,
}, },
})); }))
}; }
const crearAsignatura = async (onCreated: () => void) => { const crearAsignatura = async () => {
setWizard((w) => ({ ...w, isLoading: true })); await new Promise((r) => setTimeout(r, 1000))
await new Promise((r) => setTimeout(r, 1000)); }
onCreated();
};
return { return {
wizard, wizard,
setWizard, setWizard,
canContinueDesdeMetodo, canContinueDesdeMetodo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeConfig, canContinueDesdeDetalles,
simularGeneracionIA, simularGeneracionIA,
crearAsignatura, crearAsignatura,
}; }
} }

View File

@@ -1,45 +1,50 @@
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO"; import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
export type SubModoClonado = "INTERNO" | "TRADICIONAL"; import type { Asignatura } from '@/data'
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRO";
export type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO'
export type SubModoClonado = 'INTERNO' | 'TRADICIONAL'
export type TipoAsignatura = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO'
export type AsignaturaPreview = { export type AsignaturaPreview = {
nombre: string; nombre: string
objetivo: string; objetivo: string
unidades: number; unidades: number
bibliografiaCount: number; bibliografiaCount: number
}; }
export type NewSubjectWizardState = { export type NewSubjectWizardState = {
step: 1 | 2 | 3 | 4; step: 1 | 2 | 3 | 4
planId: string; plan_estudio_id: Asignatura['plan_estudio_id']
modoCreacion: ModoCreacion | null; tipoOrigen: Asignatura['tipo_origen'] | null
subModoClonado?: SubModoClonado;
datosBasicos: { datosBasicos: {
nombre: string; nombre: Asignatura['nombre']
clave?: string; codigo?: Asignatura['codigo']
tipo: TipoAsignatura; tipo: Asignatura['tipo'] | null
creditos: number; creditos: Asignatura['creditos'] | null
horasSemana?: number; horasAcademicas?: Asignatura['horas_academicas'] | null
estructuraId: string; horasIndependientes?: Asignatura['horas_independientes'] | null
}; estructuraId: Asignatura['estructura_id'] | null
}
clonInterno?: { clonInterno?: {
facultadId?: string; facultadId?: string
carreraId?: string; carreraId?: string
planOrigenId?: string; planOrigenId?: string
asignaturaOrigenId?: string | null; asignaturaOrigenId?: string | null
}; }
clonTradicional?: { clonTradicional?: {
archivoWordAsignaturaId: string | null; archivoWordAsignaturaId: string | null
archivosAdicionalesIds: Array<string>; archivosAdicionalesIds: Array<string>
}; }
iaConfig?: { iaConfig?: {
descripcionEnfoque: string; descripcionEnfoqueAcademico: string
notasAdicionales: string; instruccionesAdicionalesIA: string
archivosExistentesIds: Array<string>; archivosReferencia: Array<string>
}; repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
}
resumen: { resumen: {
previewAsignatura?: AsignaturaPreview; previewAsignatura?: AsignaturaPreview
}; }
isLoading: boolean; isLoading: boolean
errorMessage: string | null; errorMessage: string | null
}; }

View File

@@ -12,7 +12,7 @@ export function useNuevoPlanWizard() {
carrera: { id: '', nombre: '' }, carrera: { id: '', nombre: '' },
nivel: '', nivel: '',
tipoCiclo: '', tipoCiclo: '',
numCiclos: undefined, numCiclos: null,
estructuraPlanId: null, estructuraPlanId: null,
}, },
// datosBasicos: { // datosBasicos: {
@@ -56,7 +56,7 @@ export function useNuevoPlanWizard() {
!!wizard.datosBasicos.carrera.id && !!wizard.datosBasicos.carrera.id &&
!!wizard.datosBasicos.facultad.id && !!wizard.datosBasicos.facultad.id &&
!!wizard.datosBasicos.nivel && !!wizard.datosBasicos.nivel &&
wizard.datosBasicos.numCiclos !== undefined && wizard.datosBasicos.numCiclos !== null &&
wizard.datosBasicos.numCiclos > 0 && wizard.datosBasicos.numCiclos > 0 &&
// Requerir ambas plantillas (plan y mapa) con versión // Requerir ambas plantillas (plan y mapa) con versión
!!wizard.datosBasicos.estructuraPlanId !!wizard.datosBasicos.estructuraPlanId

View File

@@ -29,7 +29,7 @@ export type NewPlanWizardState = {
} }
nivel: NivelPlanEstudio | '' nivel: NivelPlanEstudio | ''
tipoCiclo: TipoCiclo | '' tipoCiclo: TipoCiclo | ''
numCiclos: number | undefined numCiclos: number | null
// Selección de plantillas (obligatorias) // Selección de plantillas (obligatorias)
estructuraPlanId: string | null estructuraPlanId: string | null
} }

View File

@@ -23,7 +23,7 @@ import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial' import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo' import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento' import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
import { Route as PlanesPlanIdDetalleAsignaturasIndexRouteImport } from './routes/planes/$planId/_detalle/asignaturas/index' import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva' import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
@@ -102,17 +102,17 @@ const PlanesPlanIdDetalleDocumentoRoute =
path: '/documento', path: '/documento',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
const PlanesPlanIdDetalleAsignaturasIndexRoute = const PlanesPlanIdDetalleAsignaturasRoute =
PlanesPlanIdDetalleAsignaturasIndexRouteImport.update({ PlanesPlanIdDetalleAsignaturasRouteImport.update({
id: '/asignaturas/', id: '/asignaturas',
path: '/asignaturas/', path: '/asignaturas',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
const PlanesPlanIdDetalleAsignaturasNuevaRoute = const PlanesPlanIdDetalleAsignaturasNuevaRoute =
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({ PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
id: '/asignaturas/nueva', id: '/nueva',
path: '/asignaturas/nueva', path: '/nueva',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute,
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
@@ -123,6 +123,7 @@ export interface FileRoutesByFullPath {
'/planes': typeof PlanesListaRouteWithChildren '/planes': typeof PlanesListaRouteWithChildren
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren '/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
@@ -131,7 +132,6 @@ export interface FileRoutesByFullPath {
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -140,6 +140,7 @@ export interface FileRoutesByTo {
'/demo/tanstack-query': typeof DemoTanstackQueryRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes': typeof PlanesListaRouteWithChildren '/planes': typeof PlanesListaRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
@@ -148,7 +149,6 @@ export interface FileRoutesByTo {
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -159,6 +159,7 @@ export interface FileRoutesById {
'/planes/_lista': typeof PlanesListaRouteWithChildren '/planes/_lista': typeof PlanesListaRouteWithChildren
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren '/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute '/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
@@ -167,7 +168,6 @@ export interface FileRoutesById {
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/_detalle/asignaturas/': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -179,6 +179,7 @@ export interface FileRouteTypes {
| '/planes' | '/planes'
| '/planes/$planId' | '/planes/$planId'
| '/planes/nuevo' | '/planes/nuevo'
| '/planes/$planId/asignaturas'
| '/planes/$planId/documento' | '/planes/$planId/documento'
| '/planes/$planId/flujo' | '/planes/$planId/flujo'
| '/planes/$planId/historial' | '/planes/$planId/historial'
@@ -187,7 +188,6 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/' | '/planes/$planId/'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -196,6 +196,7 @@ export interface FileRouteTypes {
| '/demo/tanstack-query' | '/demo/tanstack-query'
| '/planes' | '/planes'
| '/planes/nuevo' | '/planes/nuevo'
| '/planes/$planId/asignaturas'
| '/planes/$planId/documento' | '/planes/$planId/documento'
| '/planes/$planId/flujo' | '/planes/$planId/flujo'
| '/planes/$planId/historial' | '/planes/$planId/historial'
@@ -204,7 +205,6 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId' | '/planes/$planId'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -214,6 +214,7 @@ export interface FileRouteTypes {
| '/planes/_lista' | '/planes/_lista'
| '/planes/$planId/_detalle' | '/planes/$planId/_detalle'
| '/planes/_lista/nuevo' | '/planes/_lista/nuevo'
| '/planes/$planId/_detalle/asignaturas'
| '/planes/$planId/_detalle/documento' | '/planes/$planId/_detalle/documento'
| '/planes/$planId/_detalle/flujo' | '/planes/$planId/_detalle/flujo'
| '/planes/$planId/_detalle/historial' | '/planes/$planId/_detalle/historial'
@@ -222,7 +223,6 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/_detalle/' | '/planes/$planId/_detalle/'
| '/planes/$planId/_detalle/asignaturas/nueva' | '/planes/$planId/_detalle/asignaturas/nueva'
| '/planes/$planId/_detalle/asignaturas/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -335,19 +335,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
'/planes/$planId/_detalle/asignaturas/': { '/planes/$planId/_detalle/asignaturas': {
id: '/planes/$planId/_detalle/asignaturas/' id: '/planes/$planId/_detalle/asignaturas'
path: '/asignaturas' path: '/asignaturas'
fullPath: '/planes/$planId/asignaturas/' fullPath: '/planes/$planId/asignaturas'
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasIndexRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
'/planes/$planId/_detalle/asignaturas/nueva': { '/planes/$planId/_detalle/asignaturas/nueva': {
id: '/planes/$planId/_detalle/asignaturas/nueva' id: '/planes/$planId/_detalle/asignaturas/nueva'
path: '/asignaturas/nueva' path: '/nueva'
fullPath: '/planes/$planId/asignaturas/nueva' fullPath: '/planes/$planId/asignaturas/nueva'
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute
} }
} }
} }
@@ -364,28 +364,40 @@ const PlanesListaRouteWithChildren = PlanesListaRoute._addFileChildren(
PlanesListaRouteChildren, PlanesListaRouteChildren,
) )
interface PlanesPlanIdDetalleAsignaturasRouteChildren {
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
}
const PlanesPlanIdDetalleAsignaturasRouteChildren: PlanesPlanIdDetalleAsignaturasRouteChildren =
{
PlanesPlanIdDetalleAsignaturasNuevaRoute:
PlanesPlanIdDetalleAsignaturasNuevaRoute,
}
const PlanesPlanIdDetalleAsignaturasRouteWithChildren =
PlanesPlanIdDetalleAsignaturasRoute._addFileChildren(
PlanesPlanIdDetalleAsignaturasRouteChildren,
)
interface PlanesPlanIdDetalleRouteChildren { interface PlanesPlanIdDetalleRouteChildren {
PlanesPlanIdDetalleAsignaturasRoute: typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
PlanesPlanIdDetalleDocumentoRoute: typeof PlanesPlanIdDetalleDocumentoRoute PlanesPlanIdDetalleDocumentoRoute: typeof PlanesPlanIdDetalleDocumentoRoute
PlanesPlanIdDetalleFlujoRoute: typeof PlanesPlanIdDetalleFlujoRoute PlanesPlanIdDetalleFlujoRoute: typeof PlanesPlanIdDetalleFlujoRoute
PlanesPlanIdDetalleHistorialRoute: typeof PlanesPlanIdDetalleHistorialRoute PlanesPlanIdDetalleHistorialRoute: typeof PlanesPlanIdDetalleHistorialRoute
PlanesPlanIdDetalleIaplanRoute: typeof PlanesPlanIdDetalleIaplanRoute PlanesPlanIdDetalleIaplanRoute: typeof PlanesPlanIdDetalleIaplanRoute
PlanesPlanIdDetalleMapaRoute: typeof PlanesPlanIdDetalleMapaRoute PlanesPlanIdDetalleMapaRoute: typeof PlanesPlanIdDetalleMapaRoute
PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
PlanesPlanIdDetalleAsignaturasIndexRoute: typeof PlanesPlanIdDetalleAsignaturasIndexRoute
} }
const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = { const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
PlanesPlanIdDetalleAsignaturasRoute:
PlanesPlanIdDetalleAsignaturasRouteWithChildren,
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute, PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute, PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute, PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute, PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute, PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute, PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute,
PlanesPlanIdDetalleAsignaturasNuevaRoute:
PlanesPlanIdDetalleAsignaturasNuevaRoute,
PlanesPlanIdDetalleAsignaturasIndexRoute:
PlanesPlanIdDetalleAsignaturasIndexRoute,
} }
const PlanesPlanIdDetalleRouteWithChildren = const PlanesPlanIdDetalleRouteWithChildren =

View File

@@ -5,12 +5,10 @@ import {
Clock, Clock,
Hash, Hash,
CalendarDays, CalendarDays,
Save,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, forwardRef } from 'react' import { useState, useEffect, forwardRef } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -20,7 +18,7 @@ import {
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { plans_get } from '@/data/api/plans.api' import { plans_get } from '@/data/api/plans.api'
import { usePlan } from '@/data/hooks/usePlans' import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
import { qk } from '@/data/query/keys' import { qk } from '@/data/query/keys'
export const Route = createFileRoute('/planes/$planId/_detalle')({ export const Route = createFileRoute('/planes/$planId/_detalle')({
@@ -52,6 +50,7 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId) const { data, isLoading } = usePlan(planId)
const { mutate } = useUpdatePlanFields()
// Estados locales para manejar la edición "en vivo" antes de persistir // Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('') const [nombrePlan, setNombrePlan] = useState('')
@@ -73,31 +72,37 @@ function RouteComponent() {
'Especialidad', 'Especialidad',
] ]
const handleKeyDown = (e: React.KeyboardEvent) => { const persistChange = (patch: any) => {
mutate({ planId, patch })
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() // Evita el salto de línea e.preventDefault()
e.currentTarget.blur() // Quita el foco, lo que dispara el onBlur y "guarda" en el estado e.currentTarget.blur() // Esto dispara el onBlur automáticamente
} }
} }
const handleSave = () => { const handleBlurNombre = (e: React.FocusEvent<HTMLSpanElement>) => {
// Aquí iría tu mutation const nuevoNombre = e.currentTarget.textContent || ''
setIsDirty(false) setNombrePlan(nuevoNombre)
// Solo guardamos si el valor es realmente distinto al de la base de datos
if (nuevoNombre !== data?.nombre) {
persistChange({ nombre: nuevoNombre })
}
}
const handleSelectNivel = (n: string) => {
setNivelPlan(n)
// Guardamos inmediatamente al seleccionar
if (n !== data?.nivel) {
persistChange({ nivel: n })
}
} }
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* Botón Flotante de Guardar */}
{isDirty && (
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-8 bottom-8 z-50 duration-300">
<Button
onClick={handleSave}
className="gap-2 rounded-full bg-teal-600 px-6 shadow-xl hover:bg-teal-700"
>
<Save size={16} /> Guardar cambios del Plan
</Button>
</div>
)}
{/* 1. Header Superior */} {/* 1. Header Superior */}
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm"> <div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
<div className="px-6 py-2"> <div className="px-6 py-2">
@@ -111,62 +116,54 @@ function RouteComponent() {
</div> </div>
<div className="mx-auto max-w-400 space-y-8 p-8"> <div className="mx-auto max-w-400 space-y-8 p-8">
{/* Header del Plan */} {/* 2. Header del Plan */}
{isLoading ? ( {isLoading ? (
/* ===== SKELETON ===== */ /* ===== SKELETON ===== */
<div className="mx-auto max-w-400 p-8"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> {Array.from({ length: 6 }).map((_, i) => (
{Array.from({ length: 6 }).map((_, i) => ( <DatosGeneralesSkeleton key={i} />
<DatosGeneralesSkeleton key={i} /> ))}
))}
</div>
</div> </div>
) : ( ) : (
<> <div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row"> <div>
<div> <h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900">
<h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900"> <span>{nivelPlan} en</span>
<span>{nivelPlan} en</span> <span
<span role="textbox"
role="textbox" tabIndex={0}
tabIndex={0} contentEditable
contentEditable suppressContentEditableWarning
suppressContentEditableWarning spellCheck={false}
spellCheck={false} // Quita el subrayado rojo de error ortográfico onKeyDown={handleKeyDown}
onKeyDown={handleKeyDown} onBlur={(e) => {
onBlur={(e) => const nuevoNombre = e.currentTarget.textContent || ''
setNombrePlan(e.currentTarget.textContent || '') setNombrePlan(nuevoNombre)
if (nuevoNombre !== data?.nombre) {
mutate({ planId, patch: { nombre: nuevoNombre } })
} }
className="cursor-text border-b border-transparent decoration-transparent transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500" }}
style={{ className="cursor-text border-b border-transparent transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500"
WebkitTextDecoration: 'none', style={{ textDecoration: 'none' }}
textDecoration: 'none',
}} // Doble seguridad contra subrayados
>
{nombrePlan}
</span>
</h1>
<p className="mt-1 text-lg font-medium text-slate-500">
{data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p>
</div>
<div className="flex gap-2">
{/* <Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
<CheckCircle2 size={12} /> {data?.estados_plan?.etiqueta}
</Badge> */}
<Badge
className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`}
> >
{data?.estados_plan?.etiqueta} {nombrePlan}
</Badge> </span>
</div> </h1>
<p className="mt-1 text-lg font-medium text-slate-500">
{data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p>
</div> </div>
</>
<div className="flex gap-2">
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
{data?.estados_plan?.etiqueta}
</Badge>
</div>
</div>
)} )}
{/* 3. Cards de Información con Context Menu */} {/* 3. Cards de Información */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -184,7 +181,9 @@ function RouteComponent() {
key={n} key={n}
onClick={() => { onClick={() => {
setNivelPlan(n) setNivelPlan(n)
setIsDirty(true) if (n !== data?.nivel) {
mutate({ planId, patch: { nivel: n } })
}
}} }}
> >
{n} {n}
@@ -206,7 +205,7 @@ function RouteComponent() {
<InfoCard <InfoCard
icon={<CalendarDays className="text-slate-400" />} icon={<CalendarDays className="text-slate-400" />}
label="Creación" label="Creación"
value={data?.creado_en?.split('T')[0]} // Cortamos la fecha para que no sea tan larga value={data?.creado_en?.split('T')[0]}
/> />
</div> </div>

View File

@@ -1,4 +1,4 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router' import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
import { import {
Plus, Plus,
Copy, Copy,
@@ -72,7 +72,7 @@ const mapAsignaturas = (asigApi: Array<any> = []): Array<Asignatura> => {
})) }))
} }
export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas/')({ export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({
component: AsignaturasPage, component: AsignaturasPage,
}) })
@@ -306,6 +306,7 @@ function AsignaturasPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<Outlet />
</div> </div>
) )
} }

View File

@@ -110,15 +110,68 @@ function DatosGeneralesPage() {
}, [data]) }, [data])
// 3. Manejadores de acciones (Ahora como funciones locales) // 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (campo: DatosGeneralesField) => { const handleEdit = (nuevoCampo: DatosGeneralesField) => {
setEditingId(campo.id) // 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
setEditValue(campo.value) if (editingId && editingId !== nuevoCampo.id) {
const campoAnterior = campos.find((c) => c.id === editingId)
if (campoAnterior && editValue !== campoAnterior.value) {
// Solo guardamos si el valor realmente cambió
ejecutarGuardadoSilencioso(campoAnterior, editValue)
}
}
// 2. ABRIMOS EL NUEVO CAMPO
setEditingId(nuevoCampo.id)
setEditValue(nuevoCampo.value)
} }
const handleCancel = () => { const handleCancel = () => {
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
} }
// Función auxiliar para procesar los datos (fuera o dentro del componente)
const prepararDatosActualizados = (
data: any,
campo: DatosGeneralesField,
valor: string,
) => {
const currentValue = data.datos[campo.clave]
let newValue: any
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
newValue = { ...currentValue, description: valor }
} else {
newValue = valor
}
return {
...data.datos,
[campo.clave]: newValue,
}
}
const ejecutarGuardadoSilencioso = (
campo: DatosGeneralesField,
valor: string,
) => {
if (!data?.datos) return
const datosActualizados = prepararDatosActualizados(data, campo, valor)
updatePlan.mutate({
planId,
patch: { datos: datosActualizados },
})
// Actualizar UI localmente
setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
)
}
const handleSave = (campo: DatosGeneralesField) => { const handleSave = (campo: DatosGeneralesField) => {
if (!data?.datos) return if (!data?.datos) return
@@ -159,6 +212,7 @@ function DatosGeneralesPage() {
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)), prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
) )
ejecutarGuardadoSilencioso(campo, editValue)
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
} }

View File

@@ -1,6 +1,8 @@
import { createFileRoute, notFound } from '@tanstack/react-router' import { createFileRoute, notFound, useLocation } from '@tanstack/react-router'
import { useEffect } from 'react'
import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage' import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { subjects_get } from '@/data/api/subjects.api' import { subjects_get } from '@/data/api/subjects.api'
import { qk } from '@/data/query/keys' import { qk } from '@/data/query/keys'
@@ -35,6 +37,15 @@ export const Route = createFileRoute(
function RouteComponent() { function RouteComponent() {
// const { planId, asignaturaId } = Route.useParams() // const { planId, asignaturaId } = Route.useParams()
const location = useLocation()
// Confetti al llegar desde creación
useEffect(() => {
if ((location.state as any)?.showConfetti) {
lateralConfetti()
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
}
}, [location.state])
return ( return (
<div> <div>