From c82fac52f740a80e25dcd9e8cd9fe8af5528c3f6 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Wed, 4 Feb 2026 13:36:46 -0600 Subject: [PATCH] Refactor: unifica wizards con WizardLayout/WizardResponsiveHeader y convierte asignaturas en layout con Outlet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se introdujo un layout genérico de wizard (WizardLayout) con headerSlot/footerSlot y se migraron los modales de Nuevo Plan y Nueva Asignatura a esta estructura usando defineStepper. - Se creó y reutilizó WizardResponsiveHeader para un encabezado responsivo consistente (progreso en móvil y navegación en escritorio) en ambos wizards. - Se homologó WizardControls del wizard de asignaturas para alinearlo al patrón del wizard de planes (props onPrev/onNext, flags de disable, manejo de error/loading y creación). - Se mejoró la captura de datos en el wizard de asignatura: créditos como flotante con 2 decimales, placeholders/estilos en inputs/selects y uso de catálogo real de estructuras vía useSubjectEstructuras con qk.estructurasAsignatura. - Se reorganizó la sección de asignaturas del detalle del plan: el contenido del antiguo index se movió a asignaturas.tsx como layout y se agregó ; navegación a “nueva asignatura” ajustada al path correcto. --- .../asignaturas/wizard/PasoBasicosForm.tsx | 276 ++++++++++++++---- ...uracionPanel.tsx => PasoDetallesPanel.tsx} | 207 +++++++------ .../wizard/PasoMetodoCardGroup.tsx | 165 +++++++---- .../asignaturas/wizard/PasoResumenCard.tsx | 207 ++++++++++--- .../asignaturas/wizard/WizardControls.tsx | 93 +++--- .../PasoBasicosForm/PasoBasicosForm.tsx | 4 +- .../PasoDetallesPanel/PasoDetallesPanel.tsx | 4 +- src/data/api/subjects.api.ts | 18 ++ src/data/hooks/usePlans.ts | 2 +- src/data/hooks/useSubjects.ts | 8 + .../nueva/NuevaAsignaturaModalContainer.tsx | 29 +- .../nueva/hooks/useNuevaAsignaturaWizard.ts | 92 +++--- src/features/asignaturas/nueva/types.ts | 77 ++--- .../planes/nuevo/hooks/useNuevoPlanWizard.ts | 4 +- src/features/planes/nuevo/types.ts | 2 +- src/routeTree.gen.ts | 64 ++-- .../index.tsx => asignaturas.tsx} | 5 +- 17 files changed, 824 insertions(+), 433 deletions(-) rename src/components/asignaturas/wizard/{PasoConfiguracionPanel.tsx => PasoDetallesPanel.tsx} (57%) rename src/routes/planes/$planId/_detalle/{asignaturas/index.tsx => asignaturas.tsx} (99%) diff --git a/src/components/asignaturas/wizard/PasoBasicosForm.tsx b/src/components/asignaturas/wizard/PasoBasicosForm.tsx index 166907f..dff2726 100644 --- a/src/components/asignaturas/wizard/PasoBasicosForm.tsx +++ b/src/components/asignaturas/wizard/PasoBasicosForm.tsx @@ -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> }) { + const { data: estructuras } = useSubjectEstructuras() + + const [creditosInput, setCreditosInput] = useState(() => { + 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 (
@@ -33,45 +46,66 @@ export function PasoBasicosForm({ placeholder="Ej. Matemáticas Discretas" value={wizard.datosBasicos.nombre} onChange={(e) => - onChange((w) => ({ - ...w, - datosBasicos: { ...w.datosBasicos, nombre: e.target.value }, - })) + 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" />
- + - onChange((w) => ({ - ...w, - datosBasicos: { ...w.datosBasicos, clave: e.target.value }, - })) + onChange( + (w): NewSubjectWizardState => ({ + ...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" />
+ 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) => { + 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" />
- + - onChange((w) => ({ - ...w, - datosBasicos: { - ...w.datosBasicos, - horasSemana: Number(e.target.value || 0), - }, - })) + 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) => + 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" />
-
+
+ + { + if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) { + e.preventDefault() + } + }} + onChange={(e: React.ChangeEvent) => + 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" + /> +
+ +

diff --git a/src/components/asignaturas/wizard/PasoConfiguracionPanel.tsx b/src/components/asignaturas/wizard/PasoDetallesPanel.tsx similarity index 57% rename from src/components/asignaturas/wizard/PasoConfiguracionPanel.tsx rename to src/components/asignaturas/wizard/PasoDetallesPanel.tsx index c81fb77..612d4fc 100644 --- a/src/components/asignaturas/wizard/PasoConfiguracionPanel.tsx +++ b/src/components/asignaturas/wizard/PasoDetallesPanel.tsx @@ -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> onGenerarIA: () => void }) { - if (wizard.modoCreacion === 'MANUAL') { + if (wizard.tipoOrigen === 'MANUAL') { return ( @@ -50,116 +49,104 @@ export function PasoConfiguracionPanel({ ) } - if (wizard.modoCreacion === 'IA') { + if (wizard.tipoOrigen === 'IA') { return ( -

-
- +
+
+