Merge branch 'main' into issue/80-deshacerse-de-todos-estos-query-params-de-la-url
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ count.txt
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
diff.txt
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
NewSubjectWizardState,
|
||||
TipoAsignatura,
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
import type { Database } from '@/types/supabase'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -12,10 +12,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
ESTRUCTURAS_SEP,
|
||||
TIPOS_MATERIA,
|
||||
} from '@/features/asignaturas/nueva/catalogs'
|
||||
import { useSubjectEstructuras } from '@/data'
|
||||
import { TIPOS_MATERIA } from '@/features/asignaturas/nueva/catalogs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
@@ -24,6 +23,20 @@ export function PasoBasicosForm({
|
||||
wizard: 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 (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
@@ -33,45 +46,66 @@ export function PasoBasicosForm({
|
||||
placeholder="Ej. Matemáticas Discretas"
|
||||
value={wizard.datosBasicos.nombre}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...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 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
|
||||
id="clave"
|
||||
id="codigo"
|
||||
placeholder="Ej. MAT-101"
|
||||
value={wizard.datosBasicos.clave || ''}
|
||||
value={wizard.datosBasicos.codigo || ''}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, clave: e.target.value },
|
||||
}))
|
||||
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 className="grid gap-1">
|
||||
<Label htmlFor="tipo">Tipo</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.tipo}
|
||||
onValueChange={(val) =>
|
||||
onChange((w) => ({
|
||||
value={(wizard.datosBasicos.tipo ?? '') as string}
|
||||
onValueChange={(value: string) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, tipo: val as TipoAsignatura },
|
||||
}))
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipo: value as NewSubjectWizardState['datosBasicos']['tipo'],
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
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>
|
||||
<SelectContent>
|
||||
{TIPOS_MATERIA.map((t) => (
|
||||
@@ -87,49 +121,175 @@ export function PasoBasicosForm({
|
||||
<Label htmlFor="creditos">Créditos</Label>
|
||||
<Input
|
||||
id="creditos"
|
||||
type="number"
|
||||
min={0}
|
||||
value={wizard.datosBasicos.creditos}
|
||||
onChange={(e) =>
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="^\\d*(?:[.,]\\d{0,2})?$"
|
||||
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) => ({
|
||||
...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 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
|
||||
id="horas"
|
||||
id="horasAcademicas"
|
||||
type="number"
|
||||
min={0}
|
||||
value={wizard.datosBasicos.horasSemana || 0}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
min={1}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={wizard.datosBasicos.horasAcademicas ?? ''}
|
||||
onKeyDown={(e) => {
|
||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
horasSemana: Number(e.target.value || 0),
|
||||
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 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>
|
||||
<Select
|
||||
value={wizard.datosBasicos.estructuraId}
|
||||
value={wizard.datosBasicos.estructuraId as string}
|
||||
onValueChange={(val) =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, estructuraId: val },
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -139,11 +299,15 @@ export function PasoBasicosForm({
|
||||
<SelectValue placeholder="Selecciona plantilla..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ESTRUCTURAS_SEP.map((e) => (
|
||||
{estructuras?.map(
|
||||
(
|
||||
e: Database['public']['Tables']['estructuras_asignatura']['Row'],
|
||||
) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.label}
|
||||
{e.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
@@ -21,22 +21,21 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
ARCHIVOS_SISTEMA_MOCK,
|
||||
FACULTADES,
|
||||
MATERIAS_MOCK,
|
||||
PLANES_MOCK,
|
||||
} from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoConfiguracionPanel({
|
||||
export function PasoDetallesPanel({
|
||||
wizard,
|
||||
onChange,
|
||||
onGenerarIA,
|
||||
onGenerarIA: _onGenerarIA,
|
||||
}: {
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
onGenerarIA: () => void
|
||||
}) {
|
||||
if (wizard.modoCreacion === 'MANUAL') {
|
||||
if (wizard.tipoOrigen === 'MANUAL') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -50,116 +49,104 @@ export function PasoConfiguracionPanel({
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.modoCreacion === 'IA') {
|
||||
if (wizard.tipoOrigen === 'IA') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-1">
|
||||
<Label>Descripción del enfoque</Label>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Descripción del enfoque académico</Label>
|
||||
<Textarea
|
||||
placeholder="Ej. Asignatura teórica-práctica enfocada en patrones de diseño..."
|
||||
value={wizard.iaConfig?.descripcionEnfoque}
|
||||
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?.descripcionEnfoqueAcademico}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
descripcionEnfoque: e.target.value,
|
||||
descripcionEnfoqueAcademico: e.target.value,
|
||||
},
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="min-h-25"
|
||||
className="placeholder:text-muted-foreground/70 min-h-25 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label>Notas adicionales</Label>
|
||||
<Textarea
|
||||
placeholder="Restricciones, bibliografía sugerida, etc."
|
||||
value={wizard.iaConfig?.notasAdicionales}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: { ...w.iaConfig!, notasAdicionales: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Archivos de contexto (Opcional)</Label>
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3">
|
||||
{ARCHIVOS_SISTEMA_MOCK.map((file) => (
|
||||
<div key={file.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={file.id}
|
||||
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}
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
archivosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onToggleRepositorio={(id, checked) =>
|
||||
onChange((w): NewSubjectWizardState => {
|
||||
const prev = w.iaConfig?.repositoriosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
: prev.filter((r) => r !== id)
|
||||
return {
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
repositoriosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onFilesChange={(files: Array<UploadedFile>) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
archivosAdjuntos: files,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.subModoClonado === 'INTERNO') {
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
@@ -217,12 +204,22 @@ export function PasoConfiguracionPanel({
|
||||
{MATERIAS_MOCK.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
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 ${
|
||||
wizard.clonInterno?.asignaturaOrigenId === m.id
|
||||
? '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 (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
@@ -1,10 +1,6 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type {
|
||||
ModoCreacion,
|
||||
NewSubjectWizardState,
|
||||
SubModoClonado,
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -21,19 +17,33 @@ export function PasoMetodoCardGroup({
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
|
||||
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
|
||||
const isSelected = (modo: NewSubjectWizardState['tipoOrigen']) =>
|
||||
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 (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card
|
||||
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
modoCreacion: 'MANUAL',
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
tipoOrigen: 'MANUAL',
|
||||
}),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -51,11 +61,12 @@ export function PasoMetodoCardGroup({
|
||||
<Card
|
||||
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
modoCreacion: 'IA',
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
tipoOrigen: 'IA',
|
||||
}),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -69,8 +80,10 @@ export function PasoMetodoCardGroup({
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
|
||||
className={isSelected('OTRO') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w): NewSubjectWizardState => ({ ...w, tipoOrigen: 'OTRO' }))
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -80,18 +93,34 @@ export function PasoMetodoCardGroup({
|
||||
</CardTitle>
|
||||
<CardDescription>De otra asignatura o archivo Word.</CardDescription>
|
||||
</CardHeader>
|
||||
{wizard.modoCreacion === 'CLONADO' && (
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3">
|
||||
{(wizard.tipoOrigen === 'OTRO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_INTERNO',
|
||||
}),
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_INTERNO',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
|
||||
isSubSelected('INTERNO')
|
||||
isSelected('CLONADO_INTERNO')
|
||||
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
@@ -110,10 +139,25 @@ export function PasoMetodoCardGroup({
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_TRADICIONAL',
|
||||
}),
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_TRADICIONAL',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
|
||||
isSubSelected('TRADICIONAL')
|
||||
isSelected('CLONADO_TRADICIONAL')
|
||||
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
@@ -121,10 +165,7 @@ export function PasoMetodoCardGroup({
|
||||
<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>
|
||||
<span className="text-xs opacity-70">Subir Word existente</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -9,9 +9,38 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 }) {
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -20,53 +49,145 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
|
||||
Verifica los datos antes de crear la asignatura.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<CardContent>
|
||||
<div className="grid gap-4 text-sm">
|
||||
<div className="grid gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Nombre:</span>
|
||||
<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">Tipo:</span>
|
||||
<div className="font-medium">{wizard.datosBasicos.tipo}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Créditos:</span>
|
||||
<div className="font-medium">{wizard.datosBasicos.creditos}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Estructura:</span>
|
||||
<div className="font-medium">
|
||||
{
|
||||
ESTRUCTURAS_SEP.find(
|
||||
(e) => e.id === wizard.datosBasicos.estructuraId,
|
||||
)?.label
|
||||
}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Carrera: </span>
|
||||
<span className="font-medium">{plan.carreras.nombre}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted rounded-md p-3">
|
||||
<span className="text-muted-foreground">Modo de creación:</span>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{wizard.modoCreacion === 'MANUAL' && (
|
||||
<>
|
||||
<Icons.Pencil className="h-4 w-4" /> Manual (Vacía)
|
||||
</>
|
||||
<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.modoCreacion === 'IA' && (
|
||||
<>
|
||||
<Icons.Sparkles className="h-4 w-4" /> Generada con IA
|
||||
</>
|
||||
{wizard.tipoOrigen === 'IA' && (
|
||||
<Icons.Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{wizard.modoCreacion === 'CLONADO' && (
|
||||
<>
|
||||
<Icons.Copy className="h-4 w-4" /> Clonada
|
||||
{wizard.subModoClonado === 'INTERNO'
|
||||
? ' (Sistema)'
|
||||
: ' (Archivo)'}
|
||||
</>
|
||||
{(wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
|
||||
<Icons.Copy className="h-4 w-4" />
|
||||
)}
|
||||
{modoLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Nombre: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.nombre || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Código: </span>
|
||||
<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 className="bg-muted/50 rounded-md p-3">
|
||||
<div className="font-medium">Configuración IA</div>
|
||||
<div className="mt-2 grid gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Enfoque académico:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Instrucciones adicionales:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
|
||||
</span>
|
||||
</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>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,64 +1,116 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import type { AIGenerateSubjectInput } from '@/data'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useGenerateSubjectAI } from '@/data'
|
||||
|
||||
export function WizardControls({
|
||||
Wizard,
|
||||
methods,
|
||||
wizard,
|
||||
canContinueDesdeMetodo,
|
||||
canContinueDesdeBasicos,
|
||||
canContinueDesdeConfig,
|
||||
onCreate,
|
||||
setWizard,
|
||||
errorMessage,
|
||||
onPrev,
|
||||
onNext,
|
||||
disablePrev,
|
||||
disableNext,
|
||||
disableCreate,
|
||||
isLastStep,
|
||||
}: {
|
||||
Wizard: any
|
||||
methods: any
|
||||
wizard: NewSubjectWizardState
|
||||
canContinueDesdeMetodo: boolean
|
||||
canContinueDesdeBasicos: boolean
|
||||
canContinueDesdeConfig: boolean
|
||||
onCreate: () => void
|
||||
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
errorMessage?: string | null
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
disablePrev: boolean
|
||||
disableNext: boolean
|
||||
disableCreate: boolean
|
||||
isLastStep: boolean
|
||||
}) {
|
||||
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||
const isLast = idx >= Wizard.steps.length - 1
|
||||
const navigate = useNavigate()
|
||||
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 (
|
||||
<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">
|
||||
{wizard.errorMessage && (
|
||||
{(errorMessage ?? wizard.errorMessage) && (
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
{wizard.errorMessage}
|
||||
{errorMessage ?? wizard.errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => methods.prev()}
|
||||
disabled={idx === 0 || wizard.isLoading}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
{!isLast ? (
|
||||
<Button
|
||||
onClick={() => methods.next()}
|
||||
disabled={
|
||||
wizard.isLoading ||
|
||||
(idx === 0 && !canContinueDesdeMetodo) ||
|
||||
(idx === 1 && !canContinueDesdeBasicos) ||
|
||||
(idx === 2 && !canContinueDesdeConfig)
|
||||
}
|
||||
>
|
||||
Siguiente
|
||||
{isLastStep ? (
|
||||
<Button onClick={handleCreate} disabled={disableCreate}>
|
||||
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onCreate} disabled={wizard.isLoading}>
|
||||
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
|
||||
<Button onClick={onNext} disabled={disableNext}>
|
||||
Siguiente
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,9 +246,9 @@ export function PasoBasicosForm({
|
||||
// Keep undefined when the input is empty so the field stays optional
|
||||
numCiclos: (() => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') return undefined
|
||||
if (raw === '') return null
|
||||
const asNumber = Number(raw)
|
||||
if (Number.isNaN(asNumber)) return undefined
|
||||
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
|
||||
|
||||
@@ -90,7 +90,7 @@ export function PasoDetallesPanel({
|
||||
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
||||
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
|
||||
onToggleArchivo={(id, checked) =>
|
||||
onChange((w) => {
|
||||
onChange((w): NewPlanWizardState => {
|
||||
const prev = w.iaConfig?.archivosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
@@ -105,7 +105,7 @@ export function PasoDetallesPanel({
|
||||
})
|
||||
}
|
||||
onToggleRepositorio={(id, checked) =>
|
||||
onChange((w) => {
|
||||
onChange((w): NewPlanWizardState => {
|
||||
const prev = w.iaConfig?.repositoriosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import type { AIGeneratePlanInput } from '@/data'
|
||||
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
// import type { Database } from '@/types/supabase'
|
||||
@@ -54,11 +55,11 @@ export function WizardControls({
|
||||
? wizard.datosBasicos.numCiclos
|
||||
: 1
|
||||
|
||||
const aiInput = {
|
||||
const aiInput: AIGeneratePlanInput = {
|
||||
datosBasicos: {
|
||||
nombrePlan: wizard.datosBasicos.nombrePlan,
|
||||
carreraId: wizard.datosBasicos.carrera.id || undefined,
|
||||
facultadId: wizard.datosBasicos.facultad.id || undefined,
|
||||
carreraId: wizard.datosBasicos.carrera.id,
|
||||
facultadId: wizard.datosBasicos.facultad.id,
|
||||
nivel: wizard.datosBasicos.nivel as string,
|
||||
tipoCiclo: tipoCicloSafe,
|
||||
numCiclos: numCiclosSafe,
|
||||
@@ -77,11 +78,11 @@ export function WizardControls({
|
||||
|
||||
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
|
||||
|
||||
const data = await generatePlanAI.mutateAsync(aiInput as any)
|
||||
console.log(`${new Date().toISOString()} - Plan IA generado`, data)
|
||||
const plan = await generatePlanAI.mutateAsync(aiInput as any)
|
||||
console.log(`${new Date().toISOString()} - Plan IA generado`, plan)
|
||||
|
||||
navigate({
|
||||
to: `/planes/${data.plan.id}`,
|
||||
to: `/planes/${plan.id}`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
return
|
||||
@@ -122,7 +123,7 @@ export function WizardControls({
|
||||
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
|
||||
Anterior
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="mx-2 flex-1">
|
||||
{errorMessage && (
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
{errorMessage}
|
||||
|
||||
@@ -268,8 +268,8 @@ export type AIGeneratePlanInput = {
|
||||
estructuraPlanId: UUID
|
||||
}
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string
|
||||
notasAdicionales?: string
|
||||
descripcionEnfoqueAcademico: string
|
||||
instruccionesAdicionalesIA?: string
|
||||
archivosReferencia?: Array<UUID>
|
||||
repositoriosIds?: Array<UUID>
|
||||
archivosAdjuntos: Array<UploadedFile>
|
||||
|
||||
@@ -11,10 +11,12 @@ import type {
|
||||
TipoAsignatura,
|
||||
UUID,
|
||||
} from '../types/domain'
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
import type { Database } from '@/types/supabase'
|
||||
|
||||
const EDGE = {
|
||||
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_clone_from_existing: 'subjects_clone_from_existing',
|
||||
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)
|
||||
}
|
||||
|
||||
export async function ai_generate_subject(payload: {
|
||||
planId: UUID
|
||||
export type AIGenerateSubjectInput = {
|
||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||
datosBasicos: {
|
||||
nombre: string
|
||||
clave?: string
|
||||
tipo: TipoAsignatura
|
||||
creditos: number
|
||||
horasSemana?: number
|
||||
estructuraId: UUID
|
||||
nombre: Asignatura['nombre']
|
||||
codigo?: Asignatura['codigo']
|
||||
tipo: Asignatura['tipo'] | null
|
||||
creditos: Asignatura['creditos'] | null
|
||||
horasAcademicas?: Asignatura['horas_academicas'] | null
|
||||
horasIndependientes?: Asignatura['horas_independientes'] | null
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
}
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string
|
||||
notasAdicionales?: string
|
||||
archivosExistentesIds?: Array<UUID>
|
||||
repositoriosIds?: Array<UUID>
|
||||
archivosAdhocIds?: Array<UUID>
|
||||
usarMCP?: boolean
|
||||
// clonInterno?: {
|
||||
// facultadId?: string
|
||||
// carreraId?: string
|
||||
// planOrigenId?: string
|
||||
// asignaturaOrigenId?: string | null
|
||||
// }
|
||||
// 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: {
|
||||
@@ -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(
|
||||
asignaturaId: UUID,
|
||||
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias
|
||||
|
||||
@@ -99,7 +99,7 @@ export function usePlanDocumento(planId: UUID | null | undefined) {
|
||||
|
||||
export function useCatalogosPlanes() {
|
||||
return useQuery({
|
||||
queryKey: ['catalogos_planes'],
|
||||
queryKey: qk.estructurasPlan(),
|
||||
queryFn: getCatalogos,
|
||||
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
subjects_generate_document,
|
||||
subjects_get,
|
||||
subjects_get_document,
|
||||
subjects_get_structure_catalog,
|
||||
subjects_history,
|
||||
subjects_import_from_file,
|
||||
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 ------------------ */
|
||||
|
||||
export function useCreateSubjectManual() {
|
||||
@@ -89,7 +97,10 @@ export function useCreateSubjectManual() {
|
||||
}
|
||||
|
||||
export function useGenerateSubjectAI() {
|
||||
return useMutation({ mutationFn: ai_generate_subject })
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ai_generate_subject,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePersistSubjectFromAI() {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { supabaseBrowser } from "./client";
|
||||
import {
|
||||
FunctionsFetchError,
|
||||
FunctionsHttpError,
|
||||
FunctionsRelayError,
|
||||
} from '@supabase/supabase-js'
|
||||
|
||||
import type { Database } from "@/types/supabase";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { supabaseBrowser } from './client'
|
||||
|
||||
import type { Database } from '@/types/supabase'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
export type EdgeInvokeOptions = {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export class EdgeFunctionError extends Error {
|
||||
constructor(
|
||||
@@ -15,8 +21,8 @@ export class EdgeFunctionError extends Error {
|
||||
public readonly status?: number,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "EdgeFunctionError";
|
||||
super(message)
|
||||
this.name = 'EdgeFunctionError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,23 +40,69 @@ export async function invokeEdge<TOut>(
|
||||
opts: EdgeInvokeOptions = {},
|
||||
client?: SupabaseClient<Database>,
|
||||
): Promise<TOut> {
|
||||
const supabase = client ?? supabaseBrowser();
|
||||
const supabase = client ?? supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase.functions.invoke(functionName, {
|
||||
body,
|
||||
method: opts.method ?? "POST",
|
||||
method: opts.method ?? 'POST',
|
||||
headers: opts.headers,
|
||||
});
|
||||
})
|
||||
|
||||
if (error) {
|
||||
const anyErr = error;
|
||||
throw new EdgeFunctionError(
|
||||
anyErr.message ?? "Error en Edge Function",
|
||||
functionName,
|
||||
anyErr.status,
|
||||
anyErr,
|
||||
);
|
||||
// Valores por defecto (por si falla el parseo o es otro tipo de error)
|
||||
let message = error.message // El genérico "returned a non-2xx status code"
|
||||
let status = undefined
|
||||
let details: unknown = error
|
||||
|
||||
// 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}`
|
||||
}
|
||||
|
||||
return data as TOut;
|
||||
// 3. Lanzamos tu error personalizado con los datos reales extraídos
|
||||
throw new EdgeFunctionError(message, functionName, status, details)
|
||||
}
|
||||
|
||||
return data as TOut
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as Icons from 'lucide-react'
|
||||
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
||||
|
||||
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 { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
||||
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
||||
@@ -54,7 +54,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||
setWizard,
|
||||
canContinueDesdeMetodo,
|
||||
canContinueDesdeBasicos,
|
||||
canContinueDesdeConfig,
|
||||
canContinueDesdeDetalles,
|
||||
simularGeneracionIA,
|
||||
crearAsignatura,
|
||||
} = useNuevaAsignaturaWizard(planId)
|
||||
@@ -104,13 +104,20 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||
footerSlot={
|
||||
<Wizard.Stepper.Controls>
|
||||
<WizardControls
|
||||
Wizard={Wizard}
|
||||
methods={methods}
|
||||
errorMessage={wizard.errorMessage}
|
||||
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}
|
||||
canContinueDesdeMetodo={canContinueDesdeMetodo}
|
||||
canContinueDesdeBasicos={canContinueDesdeBasicos}
|
||||
canContinueDesdeConfig={canContinueDesdeConfig}
|
||||
onCreate={() => crearAsignatura(handleClose)}
|
||||
setWizard={setWizard}
|
||||
/>
|
||||
</Wizard.Stepper.Controls>
|
||||
}
|
||||
@@ -130,7 +137,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||
|
||||
{idx === 2 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoConfiguracionPanel
|
||||
<PasoDetallesPanel
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
onGenerarIA={simularGeneracionIA}
|
||||
|
||||
@@ -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) {
|
||||
const [wizard, setWizard] = useState<NewSubjectWizardState>({
|
||||
step: 1,
|
||||
planId,
|
||||
modoCreacion: null,
|
||||
plan_estudio_id: planId,
|
||||
tipoOrigen: null,
|
||||
datosBasicos: {
|
||||
nombre: "",
|
||||
clave: "",
|
||||
tipo: "OBLIGATORIA",
|
||||
creditos: 0,
|
||||
horasSemana: 0,
|
||||
estructuraId: "",
|
||||
nombre: '',
|
||||
codigo: '',
|
||||
tipo: null,
|
||||
creditos: null,
|
||||
horasAcademicas: null,
|
||||
horasIndependientes: null,
|
||||
estructuraId: '',
|
||||
},
|
||||
clonInterno: {},
|
||||
clonTradicional: {
|
||||
@@ -21,42 +22,47 @@ export function useNuevaAsignaturaWizard(planId: string) {
|
||||
archivosAdicionalesIds: [],
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoque: "",
|
||||
notasAdicionales: "",
|
||||
archivosExistentesIds: [],
|
||||
descripcionEnfoqueAcademico: '',
|
||||
instruccionesAdicionalesIA: '',
|
||||
archivosReferencia: [],
|
||||
repositoriosReferencia: [],
|
||||
archivosAdjuntos: [],
|
||||
},
|
||||
resumen: {},
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
})
|
||||
|
||||
const canContinueDesdeMetodo = wizard.modoCreacion === "MANUAL" ||
|
||||
wizard.modoCreacion === "IA" ||
|
||||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
|
||||
const canContinueDesdeMetodo =
|
||||
wizard.tipoOrigen === 'MANUAL' ||
|
||||
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.estructuraId;
|
||||
!!wizard.datosBasicos.estructuraId
|
||||
|
||||
const canContinueDesdeConfig = (() => {
|
||||
if (wizard.modoCreacion === "MANUAL") return true;
|
||||
if (wizard.modoCreacion === "IA") {
|
||||
return !!wizard.iaConfig?.descripcionEnfoque;
|
||||
const canContinueDesdeDetalles = (() => {
|
||||
if (wizard.tipoOrigen === 'MANUAL') return true
|
||||
if (wizard.tipoOrigen === 'IA') {
|
||||
return !!wizard.iaConfig?.descripcionEnfoqueAcademico
|
||||
}
|
||||
if (wizard.modoCreacion === "CLONADO") {
|
||||
if (wizard.subModoClonado === "INTERNO") {
|
||||
return !!wizard.clonInterno?.asignaturaOrigenId;
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||
return !!wizard.clonInterno?.asignaturaOrigenId
|
||||
}
|
||||
if (wizard.subModoClonado === "TRADICIONAL") {
|
||||
return !!wizard.clonTradicional?.archivoWordAsignaturaId;
|
||||
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
||||
return !!wizard.clonTradicional?.archivoWordAsignaturaId
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
return false
|
||||
})()
|
||||
|
||||
const simularGeneracionIA = async () => {
|
||||
setWizard((w) => ({ ...w, isLoading: true }));
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
setWizard((w) => ({ ...w, isLoading: true }))
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
@@ -64,27 +70,25 @@ export function useNuevaAsignaturaWizard(planId: string) {
|
||||
previewAsignatura: {
|
||||
nombre: w.datosBasicos.nombre,
|
||||
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,
|
||||
bibliografiaCount: 3,
|
||||
} as AsignaturaPreview,
|
||||
},
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const crearAsignatura = async (onCreated: () => void) => {
|
||||
setWizard((w) => ({ ...w, isLoading: true }));
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
onCreated();
|
||||
};
|
||||
const crearAsignatura = async () => {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
}
|
||||
|
||||
return {
|
||||
wizard,
|
||||
setWizard,
|
||||
canContinueDesdeMetodo,
|
||||
canContinueDesdeBasicos,
|
||||
canContinueDesdeConfig,
|
||||
canContinueDesdeDetalles,
|
||||
simularGeneracionIA,
|
||||
crearAsignatura,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,50 @@
|
||||
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
|
||||
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
|
||||
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRO";
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
import type { Asignatura } from '@/data'
|
||||
|
||||
export type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO'
|
||||
export type SubModoClonado = 'INTERNO' | 'TRADICIONAL'
|
||||
export type TipoAsignatura = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO'
|
||||
|
||||
export type AsignaturaPreview = {
|
||||
nombre: string;
|
||||
objetivo: string;
|
||||
unidades: number;
|
||||
bibliografiaCount: number;
|
||||
};
|
||||
nombre: string
|
||||
objetivo: string
|
||||
unidades: number
|
||||
bibliografiaCount: number
|
||||
}
|
||||
|
||||
export type NewSubjectWizardState = {
|
||||
step: 1 | 2 | 3 | 4;
|
||||
planId: string;
|
||||
modoCreacion: ModoCreacion | null;
|
||||
subModoClonado?: SubModoClonado;
|
||||
step: 1 | 2 | 3 | 4
|
||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||
tipoOrigen: Asignatura['tipo_origen'] | null
|
||||
datosBasicos: {
|
||||
nombre: string;
|
||||
clave?: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horasSemana?: number;
|
||||
estructuraId: string;
|
||||
};
|
||||
nombre: Asignatura['nombre']
|
||||
codigo?: Asignatura['codigo']
|
||||
tipo: Asignatura['tipo'] | null
|
||||
creditos: Asignatura['creditos'] | null
|
||||
horasAcademicas?: Asignatura['horas_academicas'] | null
|
||||
horasIndependientes?: Asignatura['horas_independientes'] | null
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
}
|
||||
clonInterno?: {
|
||||
facultadId?: string;
|
||||
carreraId?: string;
|
||||
planOrigenId?: string;
|
||||
asignaturaOrigenId?: string | null;
|
||||
};
|
||||
facultadId?: string
|
||||
carreraId?: string
|
||||
planOrigenId?: string
|
||||
asignaturaOrigenId?: string | null
|
||||
}
|
||||
clonTradicional?: {
|
||||
archivoWordAsignaturaId: string | null;
|
||||
archivosAdicionalesIds: Array<string>;
|
||||
};
|
||||
archivoWordAsignaturaId: string | null
|
||||
archivosAdicionalesIds: Array<string>
|
||||
}
|
||||
iaConfig?: {
|
||||
descripcionEnfoque: string;
|
||||
notasAdicionales: string;
|
||||
archivosExistentesIds: Array<string>;
|
||||
};
|
||||
descripcionEnfoqueAcademico: string
|
||||
instruccionesAdicionalesIA: string
|
||||
archivosReferencia: Array<string>
|
||||
repositoriosReferencia?: Array<string>
|
||||
archivosAdjuntos?: Array<UploadedFile>
|
||||
}
|
||||
resumen: {
|
||||
previewAsignatura?: AsignaturaPreview;
|
||||
};
|
||||
isLoading: boolean;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
previewAsignatura?: AsignaturaPreview
|
||||
}
|
||||
isLoading: boolean
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useNuevoPlanWizard() {
|
||||
carrera: { id: '', nombre: '' },
|
||||
nivel: '',
|
||||
tipoCiclo: '',
|
||||
numCiclos: undefined,
|
||||
numCiclos: null,
|
||||
estructuraPlanId: null,
|
||||
},
|
||||
// datosBasicos: {
|
||||
@@ -56,7 +56,7 @@ export function useNuevoPlanWizard() {
|
||||
!!wizard.datosBasicos.carrera.id &&
|
||||
!!wizard.datosBasicos.facultad.id &&
|
||||
!!wizard.datosBasicos.nivel &&
|
||||
wizard.datosBasicos.numCiclos !== undefined &&
|
||||
wizard.datosBasicos.numCiclos !== null &&
|
||||
wizard.datosBasicos.numCiclos > 0 &&
|
||||
// Requerir ambas plantillas (plan y mapa) con versión
|
||||
!!wizard.datosBasicos.estructuraPlanId
|
||||
|
||||
@@ -29,7 +29,7 @@ export type NewPlanWizardState = {
|
||||
}
|
||||
nivel: NivelPlanEstudio | ''
|
||||
tipoCiclo: TipoCiclo | ''
|
||||
numCiclos: number | undefined
|
||||
numCiclos: number | null
|
||||
// Selección de plantillas (obligatorias)
|
||||
estructuraPlanId: string | null
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$
|
||||
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
|
||||
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
|
||||
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'
|
||||
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
@@ -102,17 +102,17 @@ const PlanesPlanIdDetalleDocumentoRoute =
|
||||
path: '/documento',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleAsignaturasIndexRoute =
|
||||
PlanesPlanIdDetalleAsignaturasIndexRouteImport.update({
|
||||
id: '/asignaturas/',
|
||||
path: '/asignaturas/',
|
||||
const PlanesPlanIdDetalleAsignaturasRoute =
|
||||
PlanesPlanIdDetalleAsignaturasRouteImport.update({
|
||||
id: '/asignaturas',
|
||||
path: '/asignaturas',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleAsignaturasNuevaRoute =
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
|
||||
id: '/asignaturas/nueva',
|
||||
path: '/asignaturas/nueva',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
id: '/nueva',
|
||||
path: '/nueva',
|
||||
getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
@@ -123,6 +123,7 @@ export interface FileRoutesByFullPath {
|
||||
'/planes': typeof PlanesListaRouteWithChildren
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||
@@ -131,7 +132,6 @@ export interface FileRoutesByFullPath {
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
||||
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/planes/$planId/asignaturas/': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -140,6 +140,7 @@ export interface FileRoutesByTo {
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/planes': typeof PlanesListaRouteWithChildren
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||
@@ -148,7 +149,6 @@ export interface FileRoutesByTo {
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -159,6 +159,7 @@ export interface FileRoutesById {
|
||||
'/planes/_lista': typeof PlanesListaRouteWithChildren
|
||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||
@@ -167,7 +168,6 @@ export interface FileRoutesById {
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
||||
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/planes/$planId/_detalle/asignaturas/': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -179,6 +179,7 @@ export interface FileRouteTypes {
|
||||
| '/planes'
|
||||
| '/planes/$planId'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/$planId/documento'
|
||||
| '/planes/$planId/flujo'
|
||||
| '/planes/$planId/historial'
|
||||
@@ -187,7 +188,6 @@ export interface FileRouteTypes {
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/'
|
||||
| '/planes/$planId/asignaturas/nueva'
|
||||
| '/planes/$planId/asignaturas/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -196,6 +196,7 @@ export interface FileRouteTypes {
|
||||
| '/demo/tanstack-query'
|
||||
| '/planes'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/$planId/documento'
|
||||
| '/planes/$planId/flujo'
|
||||
| '/planes/$planId/historial'
|
||||
@@ -204,7 +205,6 @@ export interface FileRouteTypes {
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId'
|
||||
| '/planes/$planId/asignaturas/nueva'
|
||||
| '/planes/$planId/asignaturas'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -214,6 +214,7 @@ export interface FileRouteTypes {
|
||||
| '/planes/_lista'
|
||||
| '/planes/$planId/_detalle'
|
||||
| '/planes/_lista/nuevo'
|
||||
| '/planes/$planId/_detalle/asignaturas'
|
||||
| '/planes/$planId/_detalle/documento'
|
||||
| '/planes/$planId/_detalle/flujo'
|
||||
| '/planes/$planId/_detalle/historial'
|
||||
@@ -222,7 +223,6 @@ export interface FileRouteTypes {
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/_detalle/'
|
||||
| '/planes/$planId/_detalle/asignaturas/nueva'
|
||||
| '/planes/$planId/_detalle/asignaturas/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -335,19 +335,19 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/asignaturas/': {
|
||||
id: '/planes/$planId/_detalle/asignaturas/'
|
||||
'/planes/$planId/_detalle/asignaturas': {
|
||||
id: '/planes/$planId/_detalle/asignaturas'
|
||||
path: '/asignaturas'
|
||||
fullPath: '/planes/$planId/asignaturas/'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasIndexRouteImport
|
||||
fullPath: '/planes/$planId/asignaturas'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/asignaturas/nueva': {
|
||||
id: '/planes/$planId/_detalle/asignaturas/nueva'
|
||||
path: '/asignaturas/nueva'
|
||||
path: '/nueva'
|
||||
fullPath: '/planes/$planId/asignaturas/nueva'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,28 +364,40 @@ const PlanesListaRouteWithChildren = PlanesListaRoute._addFileChildren(
|
||||
PlanesListaRouteChildren,
|
||||
)
|
||||
|
||||
interface PlanesPlanIdDetalleAsignaturasRouteChildren {
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
}
|
||||
|
||||
const PlanesPlanIdDetalleAsignaturasRouteChildren: PlanesPlanIdDetalleAsignaturasRouteChildren =
|
||||
{
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRoute:
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRoute,
|
||||
}
|
||||
|
||||
const PlanesPlanIdDetalleAsignaturasRouteWithChildren =
|
||||
PlanesPlanIdDetalleAsignaturasRoute._addFileChildren(
|
||||
PlanesPlanIdDetalleAsignaturasRouteChildren,
|
||||
)
|
||||
|
||||
interface PlanesPlanIdDetalleRouteChildren {
|
||||
PlanesPlanIdDetalleAsignaturasRoute: typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
PlanesPlanIdDetalleDocumentoRoute: typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
PlanesPlanIdDetalleFlujoRoute: typeof PlanesPlanIdDetalleFlujoRoute
|
||||
PlanesPlanIdDetalleHistorialRoute: typeof PlanesPlanIdDetalleHistorialRoute
|
||||
PlanesPlanIdDetalleIaplanRoute: typeof PlanesPlanIdDetalleIaplanRoute
|
||||
PlanesPlanIdDetalleMapaRoute: typeof PlanesPlanIdDetalleMapaRoute
|
||||
PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
PlanesPlanIdDetalleAsignaturasIndexRoute: typeof PlanesPlanIdDetalleAsignaturasIndexRoute
|
||||
}
|
||||
|
||||
const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
|
||||
PlanesPlanIdDetalleAsignaturasRoute:
|
||||
PlanesPlanIdDetalleAsignaturasRouteWithChildren,
|
||||
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
|
||||
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
|
||||
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
|
||||
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
|
||||
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
|
||||
PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute,
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRoute:
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRoute,
|
||||
PlanesPlanIdDetalleAsignaturasIndexRoute:
|
||||
PlanesPlanIdDetalleAsignaturasIndexRoute,
|
||||
}
|
||||
|
||||
const PlanesPlanIdDetalleRouteWithChildren =
|
||||
|
||||
@@ -5,12 +5,10 @@ import {
|
||||
Clock,
|
||||
Hash,
|
||||
CalendarDays,
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, forwardRef } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -20,7 +18,7 @@ import {
|
||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
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'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle')({
|
||||
@@ -52,6 +50,7 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
|
||||
function RouteComponent() {
|
||||
const { planId } = Route.useParams()
|
||||
const { data, isLoading } = usePlan(planId)
|
||||
const { mutate } = useUpdatePlanFields()
|
||||
|
||||
// Estados locales para manejar la edición "en vivo" antes de persistir
|
||||
const [nombrePlan, setNombrePlan] = useState('')
|
||||
@@ -73,31 +72,37 @@ function RouteComponent() {
|
||||
'Especialidad',
|
||||
]
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const persistChange = (patch: any) => {
|
||||
mutate({ planId, patch })
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault() // Evita el salto de línea
|
||||
e.currentTarget.blur() // Quita el foco, lo que dispara el onBlur y "guarda" en el estado
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur() // Esto disparará el onBlur automáticamente
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
// Aquí iría tu mutation
|
||||
setIsDirty(false)
|
||||
const handleBlurNombre = (e: React.FocusEvent<HTMLSpanElement>) => {
|
||||
const nuevoNombre = e.currentTarget.textContent || ''
|
||||
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 (
|
||||
<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 */}
|
||||
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
|
||||
<div className="px-6 py-2">
|
||||
@@ -111,18 +116,15 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-400 space-y-8 p-8">
|
||||
{/* Header del Plan */}
|
||||
{/* 2. Header del Plan */}
|
||||
{isLoading ? (
|
||||
/* ===== SKELETON ===== */
|
||||
<div className="mx-auto max-w-400 p-8">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<DatosGeneralesSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
|
||||
<div>
|
||||
<h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900">
|
||||
@@ -132,16 +134,17 @@ function RouteComponent() {
|
||||
tabIndex={0}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false} // Quita el subrayado rojo de error ortográfico
|
||||
spellCheck={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={(e) =>
|
||||
setNombrePlan(e.currentTarget.textContent || '')
|
||||
onBlur={(e) => {
|
||||
const nuevoNombre = 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={{
|
||||
WebkitTextDecoration: 'none',
|
||||
textDecoration: 'none',
|
||||
}} // Doble seguridad contra subrayados
|
||||
}}
|
||||
className="cursor-text border-b border-transparent transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
{nombrePlan}
|
||||
</span>
|
||||
@@ -153,20 +156,14 @@ function RouteComponent() {
|
||||
</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`}
|
||||
>
|
||||
<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">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -184,7 +181,9 @@ function RouteComponent() {
|
||||
key={n}
|
||||
onClick={() => {
|
||||
setNivelPlan(n)
|
||||
setIsDirty(true)
|
||||
if (n !== data?.nivel) {
|
||||
mutate({ planId, patch: { nivel: n } })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
@@ -206,7 +205,7 @@ function RouteComponent() {
|
||||
<InfoCard
|
||||
icon={<CalendarDays className="text-slate-400" />}
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Plus,
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -306,6 +306,7 @@ function AsignaturasPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -110,15 +110,68 @@ function DatosGeneralesPage() {
|
||||
}, [data])
|
||||
|
||||
// 3. Manejadores de acciones (Ahora como funciones locales)
|
||||
const handleEdit = (campo: DatosGeneralesField) => {
|
||||
setEditingId(campo.id)
|
||||
setEditValue(campo.value)
|
||||
const handleEdit = (nuevoCampo: DatosGeneralesField) => {
|
||||
// 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
|
||||
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 = () => {
|
||||
setEditingId(null)
|
||||
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) => {
|
||||
if (!data?.datos) return
|
||||
@@ -159,6 +212,7 @@ function DatosGeneralesPage() {
|
||||
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
|
||||
)
|
||||
|
||||
ejecutarGuardadoSilencioso(campo, editValue)
|
||||
setEditingId(null)
|
||||
setEditValue('')
|
||||
}
|
||||
|
||||
@@ -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 { lateralConfetti } from '@/components/ui/lateral-confetti'
|
||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||
import { subjects_get } from '@/data/api/subjects.api'
|
||||
import { qk } from '@/data/query/keys'
|
||||
@@ -35,6 +37,15 @@ export const Route = createFileRoute(
|
||||
|
||||
function RouteComponent() {
|
||||
// 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 (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user