Separación vista/lógica de wizard de creación de asignatura
This commit is contained in:
155
src/components/asignaturas/wizard/PasoBasicosForm.tsx
Normal file
155
src/components/asignaturas/wizard/PasoBasicosForm.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import type {
|
||||||
|
NewSubjectWizardState,
|
||||||
|
TipoAsignatura,
|
||||||
|
} from '@/features/asignaturas/new/types'
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
ESTRUCTURAS_SEP,
|
||||||
|
TIPOS_MATERIA,
|
||||||
|
} from '@/features/asignaturas/new/catalogs'
|
||||||
|
|
||||||
|
export function PasoBasicosForm({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="grid gap-1 sm:col-span-2">
|
||||||
|
<Label htmlFor="nombre">Nombre de la asignatura</Label>
|
||||||
|
<Input
|
||||||
|
id="nombre"
|
||||||
|
placeholder="Ej. Matemáticas Discretas"
|
||||||
|
value={wizard.datosBasicos.nombre}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="clave">Clave (Opcional)</Label>
|
||||||
|
<Input
|
||||||
|
id="clave"
|
||||||
|
placeholder="Ej. MAT-101"
|
||||||
|
value={wizard.datosBasicos.clave || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: { ...w.datosBasicos, clave: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="tipo">Tipo</Label>
|
||||||
|
<Select
|
||||||
|
value={wizard.datosBasicos.tipo}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: { ...w.datosBasicos, tipo: val as TipoAsignatura },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="tipo"
|
||||||
|
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIPOS_MATERIA.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="creditos">Créditos</Label>
|
||||||
|
<Input
|
||||||
|
id="creditos"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={wizard.datosBasicos.creditos}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
creditos: Number(e.target.value || 0),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="horas">Horas / Semana</Label>
|
||||||
|
<Input
|
||||||
|
id="horas"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={wizard.datosBasicos.horasSemana || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
horasSemana: Number(e.target.value || 0),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1 sm:col-span-2">
|
||||||
|
<Label htmlFor="estructura">Estructura de la asignatura</Label>
|
||||||
|
<Select
|
||||||
|
value={wizard.datosBasicos.estructuraId}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: { ...w.datosBasicos, estructuraId: val },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="estructura"
|
||||||
|
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Selecciona plantilla..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ESTRUCTURAS_SEP.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={e.id}>
|
||||||
|
{e.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
286
src/components/asignaturas/wizard/PasoConfiguracionPanel.tsx
Normal file
286
src/components/asignaturas/wizard/PasoConfiguracionPanel.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
ARCHIVOS_SISTEMA_MOCK,
|
||||||
|
FACULTADES,
|
||||||
|
MATERIAS_MOCK,
|
||||||
|
PLANES_MOCK,
|
||||||
|
} from '@/features/asignaturas/new/catalogs'
|
||||||
|
|
||||||
|
export function PasoConfiguracionPanel({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
onGenerarIA,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
onGenerarIA: () => void
|
||||||
|
}) {
|
||||||
|
if (wizard.modoCreacion === 'MANUAL') {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Configuración Manual</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
La asignatura se creará vacía. Podrás editar el contenido detallado
|
||||||
|
en la siguiente pantalla.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wizard.modoCreacion === 'IA') {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Descripción del enfoque</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Ej. Asignatura teórica-práctica enfocada en patrones de diseño..."
|
||||||
|
value={wizard.iaConfig?.descripcionEnfoque}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
iaConfig: {
|
||||||
|
...w.iaConfig!,
|
||||||
|
descripcionEnfoque: e.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={onGenerarIA} disabled={wizard.isLoading}>
|
||||||
|
{wizard.isLoading ? (
|
||||||
|
<>
|
||||||
|
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Generando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icons.Sparkles className="mr-2 h-4 w-4" /> Generar Preview
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wizard.resumen.previewAsignatura && (
|
||||||
|
<Card className="bg-muted/50 border-dashed">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Vista previa generada</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>Objetivo:</strong>{' '}
|
||||||
|
{wizard.resumen.previewAsignatura.objetivo}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
Se detectaron {wizard.resumen.previewAsignatura.unidades}{' '}
|
||||||
|
unidades temáticas y{' '}
|
||||||
|
{wizard.resumen.previewAsignatura.bibliografiaCount} fuentes
|
||||||
|
bibliográficas.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wizard.subModoClonado === 'INTERNO') {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label>Facultad</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonInterno: { ...w.clonInterno, facultadId: val },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Todas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FACULTADES.map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>
|
||||||
|
{f.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Plan</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonInterno: { ...w.clonInterno, planOrigenId: val },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Todos" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLANES_MOCK.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Buscar</Label>
|
||||||
|
<Input placeholder="Nombre..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid max-h-[300px] gap-2 overflow-y-auto">
|
||||||
|
{MATERIAS_MOCK.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
onClick={() =>
|
||||||
|
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'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{m.nombre}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{m.clave} • {m.creditos} créditos
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{wizard.clonInterno?.asignaturaOrigenId === m.id && (
|
||||||
|
<Icons.CheckCircle2 className="text-primary h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wizard.subModoClonado === 'TRADICIONAL') {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||||
|
<Icons.Upload className="text-muted-foreground mx-auto mb-4 h-10 w-10" />
|
||||||
|
<h3 className="mb-1 text-sm font-medium">
|
||||||
|
Sube el Word de la asignatura
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-4 text-xs">
|
||||||
|
Arrastra el archivo o haz clic para buscar (.doc, .docx)
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept=".doc,.docx"
|
||||||
|
className="mx-auto max-w-xs"
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonTradicional: {
|
||||||
|
...w.clonTradicional!,
|
||||||
|
archivoWordAsignaturaId:
|
||||||
|
e.target.files?.[0]?.name || 'mock_file',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{wizard.clonTradicional?.archivoWordAsignaturaId && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-green-50 p-3 text-sm text-green-700">
|
||||||
|
<Icons.FileText className="h-4 w-4" />
|
||||||
|
Archivo cargado listo para procesar.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
135
src/components/asignaturas/wizard/PasoMetodoCardGroup.tsx
Normal file
135
src/components/asignaturas/wizard/PasoMetodoCardGroup.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ModoCreacion,
|
||||||
|
NewSubjectWizardState,
|
||||||
|
SubModoClonado,
|
||||||
|
} from '@/features/asignaturas/new/types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
|
||||||
|
export function PasoMetodoCardGroup({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
}) {
|
||||||
|
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
|
||||||
|
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Card
|
||||||
|
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
|
||||||
|
onClick={() =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
modoCreacion: 'MANUAL',
|
||||||
|
subModoClonado: undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Pencil className="text-primary h-5 w-5" /> Manual
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Asignatura vacía con estructura base.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
||||||
|
onClick={() =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
modoCreacion: 'IA',
|
||||||
|
subModoClonado: undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Sparkles className="text-primary h-5 w-5" /> Con IA
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Generar contenido automático.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
|
||||||
|
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Copy className="text-primary h-5 w-5" /> Clonado
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>De otra asignatura o archivo Word.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{wizard.modoCreacion === 'CLONADO' && (
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange((w) => ({ ...w, subModoClonado: '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')
|
||||||
|
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||||
|
: 'border-border text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icons.Database className="h-6 w-6 flex-none" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">Del sistema</span>
|
||||||
|
<span className="text-xs opacity-70">
|
||||||
|
Buscar en otros planes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange((w) => ({ ...w, subModoClonado: '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')
|
||||||
|
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||||
|
: 'border-border text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icons.Upload className="h-6 w-6 flex-none" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">Desde archivos</span>
|
||||||
|
<span className="text-xs opacity-70">
|
||||||
|
Subir Word existente
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/components/asignaturas/wizard/PasoResumenCard.tsx
Normal file
75
src/components/asignaturas/wizard/PasoResumenCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/new/catalogs'
|
||||||
|
|
||||||
|
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Resumen de creación</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Nombre:</span>
|
||||||
|
<div className="font-medium">{wizard.datosBasicos.nombre}</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</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)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{wizard.modoCreacion === 'IA' && (
|
||||||
|
<>
|
||||||
|
<Icons.Sparkles className="h-4 w-4" /> Generada con IA
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{wizard.modoCreacion === 'CLONADO' && (
|
||||||
|
<>
|
||||||
|
<Icons.Copy className="h-4 w-4" /> Clonada
|
||||||
|
{wizard.subModoClonado === 'INTERNO'
|
||||||
|
? ' (Sistema)'
|
||||||
|
: ' (Archivo)'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/asignaturas/wizard/StepWithTooltip.tsx
Normal file
40
src/components/asignaturas/wizard/StepWithTooltip.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
export function StepWithTooltip({
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className="cursor-help decoration-dotted underline-offset-4 hover:underline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsOpen((prev) => !prev)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setIsOpen(true)}
|
||||||
|
onMouseLeave={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[200px] text-xs">
|
||||||
|
<p>{desc}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/components/asignaturas/wizard/VistaSinPermisos.tsx
Normal file
39
src/components/asignaturas/wizard/VistaSinPermisos.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
export function VistaSinPermisos({ onClose }: { onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="flex-none border-b p-6">
|
||||||
|
<DialogTitle>Nueva Asignatura</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive flex items-center gap-2">
|
||||||
|
<Icons.ShieldAlert className="h-5 w-5" />
|
||||||
|
Sin permisos
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Solo el Jefe de Carrera puede crear asignaturas.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-end">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/components/asignaturas/wizard/WizardControls.tsx
Normal file
66
src/components/asignaturas/wizard/WizardControls.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function WizardControls({
|
||||||
|
Wizard,
|
||||||
|
methods,
|
||||||
|
wizard,
|
||||||
|
canContinueDesdeMetodo,
|
||||||
|
canContinueDesdeBasicos,
|
||||||
|
canContinueDesdeConfig,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
Wizard: any
|
||||||
|
methods: any
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
canContinueDesdeMetodo: boolean
|
||||||
|
canContinueDesdeBasicos: boolean
|
||||||
|
canContinueDesdeConfig: boolean
|
||||||
|
onCreate: () => void
|
||||||
|
}) {
|
||||||
|
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||||
|
const isLast = idx >= Wizard.steps.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-none border-t bg-white p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
{wizard.errorMessage && (
|
||||||
|
<span className="text-destructive text-sm font-medium">
|
||||||
|
{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
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onCreate} disabled={wizard.isLoading}>
|
||||||
|
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/components/asignaturas/wizard/WizardHeader.tsx
Normal file
80
src/components/asignaturas/wizard/WizardHeader.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import { StepWithTooltip } from './StepWithTooltip'
|
||||||
|
|
||||||
|
import { CircularProgress } from '@/components/CircularProgress'
|
||||||
|
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
export function WizardHeader({
|
||||||
|
title,
|
||||||
|
Wizard,
|
||||||
|
methods,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
Wizard: any
|
||||||
|
methods: any
|
||||||
|
}) {
|
||||||
|
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
|
||||||
|
const totalSteps = Wizard.steps.length
|
||||||
|
const nextStep = Wizard.steps[currentIndex]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="z-10 flex-none border-b bg-white">
|
||||||
|
<div className="flex items-center justify-between p-6 pb-4">
|
||||||
|
<DialogHeader className="p-0">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{methods.onClose && (
|
||||||
|
<button
|
||||||
|
onClick={methods.onClose}
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<Icons.X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Cerrar</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="block sm:hidden">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<CircularProgress current={currentIndex} total={totalSteps} />
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900">
|
||||||
|
<StepWithTooltip
|
||||||
|
title={methods.current.title}
|
||||||
|
desc={methods.current.description}
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
{nextStep ? (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Siguiente: {nextStep.title}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium text-green-500">
|
||||||
|
¡Último paso!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<Wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
|
||||||
|
{Wizard.steps.map((step: any) => (
|
||||||
|
<Wizard.Stepper.Step
|
||||||
|
key={step.id}
|
||||||
|
of={step.id}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Wizard.Stepper.Title>
|
||||||
|
<StepWithTooltip title={step.title} desc={step.description} />
|
||||||
|
</Wizard.Stepper.Title>
|
||||||
|
</Wizard.Stepper.Step>
|
||||||
|
))}
|
||||||
|
</Wizard.Stepper.Navigation>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -110,7 +110,8 @@ export function PasoDetallesPanel({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Preview IA</CardTitle>
|
<CardTitle>Preview IA</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Materias aprox.: {wizard.resumen.previewPlan.numMateriasAprox}
|
Asignaturas aprox.:{' '}
|
||||||
|
{wizard.resumen.previewPlan.numAsignaturasAprox}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -283,9 +284,9 @@ export function PasoDetallesPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="materias">Excel/listado de materias</Label>
|
<Label htmlFor="asignaturas">Excel/listado de asignaturas</Label>
|
||||||
<input
|
<input
|
||||||
id="materias"
|
id="asignaturas"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".xls,.xlsx,.csv"
|
accept=".xls,.xlsx,.csv"
|
||||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
@@ -294,7 +295,7 @@ export function PasoDetallesPanel({
|
|||||||
...w,
|
...w,
|
||||||
clonTradicional: {
|
clonTradicional: {
|
||||||
...(w.clonTradicional || ({} as any)),
|
...(w.clonTradicional || ({} as any)),
|
||||||
archivoMateriasExcelId: e.target.files?.[0]
|
archivoAsignaturasExcelId: e.target.files?.[0]
|
||||||
? `file_${e.target.files[0].name}`
|
? `file_${e.target.files[0].name}`
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
|||||||
<div className="bg-muted mt-2 rounded-md p-3">
|
<div className="bg-muted mt-2 rounded-md p-3">
|
||||||
<div className="font-medium">Preview IA</div>
|
<div className="font-medium">Preview IA</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
Materias aprox.: {wizard.resumen.previewPlan.numMateriasAprox}
|
Asignaturas aprox.:{' '}
|
||||||
|
{wizard.resumen.previewPlan.numAsignaturasAprox}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
127
src/features/asignaturas/new/NuevaAsignaturaModalContainer.tsx
Normal file
127
src/features/asignaturas/new/NuevaAsignaturaModalContainer.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
||||||
|
|
||||||
|
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm'
|
||||||
|
import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel'
|
||||||
|
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
|
||||||
|
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
||||||
|
import { VistaSinPermisos } from '@/components/asignaturas/wizard/VistaSinPermisos'
|
||||||
|
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
||||||
|
import { WizardHeader } from '@/components/asignaturas/wizard/WizardHeader'
|
||||||
|
import { defineStepper } from '@/components/stepper'
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
const Wizard = defineStepper(
|
||||||
|
{
|
||||||
|
id: 'metodo',
|
||||||
|
title: 'Método',
|
||||||
|
description: 'Manual, IA o Clonado',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'basicos',
|
||||||
|
title: 'Datos básicos',
|
||||||
|
description: 'Nombre y estructura',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'configuracion',
|
||||||
|
title: 'Configuración',
|
||||||
|
description: 'Detalles según modo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resumen',
|
||||||
|
title: 'Resumen',
|
||||||
|
description: 'Confirmar creación',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
|
||||||
|
|
||||||
|
export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const role = auth_get_current_user_role()
|
||||||
|
|
||||||
|
const {
|
||||||
|
wizard,
|
||||||
|
setWizard,
|
||||||
|
canContinueDesdeMetodo,
|
||||||
|
canContinueDesdeBasicos,
|
||||||
|
canContinueDesdeConfig,
|
||||||
|
simularGeneracionIA,
|
||||||
|
crearAsignatura,
|
||||||
|
} = useNuevaAsignaturaWizard(planId)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||||
|
<DialogContent
|
||||||
|
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{role !== 'JEFE_CARRERA' ? (
|
||||||
|
<VistaSinPermisos onClose={handleClose} />
|
||||||
|
) : (
|
||||||
|
<Wizard.Stepper.Provider
|
||||||
|
initialStep={Wizard.utils.getFirst().id}
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
>
|
||||||
|
{({ methods }) => (
|
||||||
|
<>
|
||||||
|
<WizardHeader
|
||||||
|
title="Nueva Asignatura"
|
||||||
|
Wizard={Wizard}
|
||||||
|
methods={{ ...methods, onClose: handleClose }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
{Wizard.utils.getIndex(methods.current.id) === 0 && (
|
||||||
|
<Wizard.Stepper.Panel>
|
||||||
|
<PasoMetodoCardGroup
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={setWizard}
|
||||||
|
/>
|
||||||
|
</Wizard.Stepper.Panel>
|
||||||
|
)}
|
||||||
|
{Wizard.utils.getIndex(methods.current.id) === 1 && (
|
||||||
|
<Wizard.Stepper.Panel>
|
||||||
|
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||||
|
</Wizard.Stepper.Panel>
|
||||||
|
)}
|
||||||
|
{Wizard.utils.getIndex(methods.current.id) === 2 && (
|
||||||
|
<Wizard.Stepper.Panel>
|
||||||
|
<PasoConfiguracionPanel
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={setWizard}
|
||||||
|
onGenerarIA={simularGeneracionIA}
|
||||||
|
/>
|
||||||
|
</Wizard.Stepper.Panel>
|
||||||
|
)}
|
||||||
|
{Wizard.utils.getIndex(methods.current.id) === 3 && (
|
||||||
|
<Wizard.Stepper.Panel>
|
||||||
|
<PasoResumenCard wizard={wizard} />
|
||||||
|
</Wizard.Stepper.Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WizardControls
|
||||||
|
Wizard={Wizard}
|
||||||
|
methods={methods}
|
||||||
|
wizard={wizard}
|
||||||
|
canContinueDesdeMetodo={canContinueDesdeMetodo}
|
||||||
|
canContinueDesdeBasicos={canContinueDesdeBasicos}
|
||||||
|
canContinueDesdeConfig={canContinueDesdeConfig}
|
||||||
|
onCreate={() => crearAsignatura(handleClose)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Wizard.Stepper.Provider>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/features/asignaturas/new/catalogs.ts
Normal file
56
src/features/asignaturas/new/catalogs.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { TipoAsignatura } from "./types";
|
||||||
|
|
||||||
|
export const ESTRUCTURAS_SEP = [
|
||||||
|
{ id: "sep-lic-2025", label: "Licenciatura SEP v2025" },
|
||||||
|
{ id: "sep-pos-2023", label: "Posgrado SEP v2023" },
|
||||||
|
{ id: "ulsa-int-2024", label: "Estándar Interno ULSA 2024" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TIPOS_MATERIA: Array<{ value: TipoAsignatura; label: string }> = [
|
||||||
|
{ value: "OBLIGATORIA", label: "Obligatoria" },
|
||||||
|
{ value: "OPTATIVA", label: "Optativa" },
|
||||||
|
{ value: "TRONCAL", label: "Troncal / Eje común" },
|
||||||
|
{ value: "OTRO", label: "Otro" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FACULTADES = [
|
||||||
|
{ id: "ing", nombre: "Facultad de Ingeniería" },
|
||||||
|
{ id: "med", nombre: "Facultad de Medicina" },
|
||||||
|
{ id: "neg", nombre: "Facultad de Negocios" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CARRERAS = [
|
||||||
|
{ id: "sis", nombre: "Ing. en Sistemas", facultadId: "ing" },
|
||||||
|
{ id: "ind", nombre: "Ing. Industrial", facultadId: "ing" },
|
||||||
|
{ id: "medico", nombre: "Médico Cirujano", facultadId: "med" },
|
||||||
|
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PLANES_MOCK = [
|
||||||
|
{ id: "p1", nombre: "Plan 2010 Sistemas", carreraId: "sis" },
|
||||||
|
{ id: "p2", nombre: "Plan 2016 Sistemas", carreraId: "sis" },
|
||||||
|
{ id: "p3", nombre: "Plan 2015 Industrial", carreraId: "ind" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MATERIAS_MOCK = [
|
||||||
|
{
|
||||||
|
id: "m1",
|
||||||
|
nombre: "Programación Orientada a Objetos",
|
||||||
|
creditos: 8,
|
||||||
|
clave: "POO-101",
|
||||||
|
},
|
||||||
|
{ id: "m2", nombre: "Cálculo Diferencial", creditos: 6, clave: "MAT-101" },
|
||||||
|
{ id: "m3", nombre: "Ética Profesional", creditos: 4, clave: "HUM-302" },
|
||||||
|
{
|
||||||
|
id: "m4",
|
||||||
|
nombre: "Bases de Datos Avanzadas",
|
||||||
|
creditos: 8,
|
||||||
|
clave: "BD-201",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ARCHIVOS_SISTEMA_MOCK = [
|
||||||
|
{ id: "doc1", name: "Sílabo_Base_Ingenieria.pdf" },
|
||||||
|
{ id: "doc2", name: "Competencias_Egreso_2025.docx" },
|
||||||
|
{ id: "doc3", name: "Reglamento_Academico.pdf" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { AsignaturaPreview, NewSubjectWizardState } from "../types";
|
||||||
|
|
||||||
|
export function useNuevaAsignaturaWizard(planId: string) {
|
||||||
|
const [wizard, setWizard] = useState<NewSubjectWizardState>({
|
||||||
|
step: 1,
|
||||||
|
planId,
|
||||||
|
modoCreacion: null,
|
||||||
|
datosBasicos: {
|
||||||
|
nombre: "",
|
||||||
|
clave: "",
|
||||||
|
tipo: "OBLIGATORIA",
|
||||||
|
creditos: 0,
|
||||||
|
horasSemana: 0,
|
||||||
|
estructuraId: "",
|
||||||
|
},
|
||||||
|
clonInterno: {},
|
||||||
|
clonTradicional: {
|
||||||
|
archivoWordAsignaturaId: null,
|
||||||
|
archivosAdicionalesIds: [],
|
||||||
|
},
|
||||||
|
iaConfig: {
|
||||||
|
descripcionEnfoque: "",
|
||||||
|
notasAdicionales: "",
|
||||||
|
archivosExistentesIds: [],
|
||||||
|
},
|
||||||
|
resumen: {},
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canContinueDesdeMetodo = wizard.modoCreacion === "MANUAL" ||
|
||||||
|
wizard.modoCreacion === "IA" ||
|
||||||
|
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
|
||||||
|
|
||||||
|
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombre &&
|
||||||
|
wizard.datosBasicos.creditos > 0 &&
|
||||||
|
!!wizard.datosBasicos.estructuraId;
|
||||||
|
|
||||||
|
const canContinueDesdeConfig = (() => {
|
||||||
|
if (wizard.modoCreacion === "MANUAL") return true;
|
||||||
|
if (wizard.modoCreacion === "IA") {
|
||||||
|
return !!wizard.iaConfig?.descripcionEnfoque;
|
||||||
|
}
|
||||||
|
if (wizard.modoCreacion === "CLONADO") {
|
||||||
|
if (wizard.subModoClonado === "INTERNO") {
|
||||||
|
return !!wizard.clonInterno?.asignaturaOrigenId;
|
||||||
|
}
|
||||||
|
if (wizard.subModoClonado === "TRADICIONAL") {
|
||||||
|
return !!wizard.clonTradicional?.archivoWordAsignaturaId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const simularGeneracionIA = async () => {
|
||||||
|
setWizard((w) => ({ ...w, isLoading: true }));
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
isLoading: false,
|
||||||
|
resumen: {
|
||||||
|
previewAsignatura: {
|
||||||
|
nombre: w.datosBasicos.nombre,
|
||||||
|
objetivo:
|
||||||
|
"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();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
wizard,
|
||||||
|
setWizard,
|
||||||
|
canContinueDesdeMetodo,
|
||||||
|
canContinueDesdeBasicos,
|
||||||
|
canContinueDesdeConfig,
|
||||||
|
simularGeneracionIA,
|
||||||
|
crearAsignatura,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/features/asignaturas/new/types.ts
Normal file
45
src/features/asignaturas/new/types.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NewSubjectWizardState = {
|
||||||
|
step: 1 | 2 | 3 | 4;
|
||||||
|
planId: string;
|
||||||
|
modoCreacion: ModoCreacion | null;
|
||||||
|
subModoClonado?: SubModoClonado;
|
||||||
|
datosBasicos: {
|
||||||
|
nombre: string;
|
||||||
|
clave?: string;
|
||||||
|
tipo: TipoAsignatura;
|
||||||
|
creditos: number;
|
||||||
|
horasSemana?: number;
|
||||||
|
estructuraId: string;
|
||||||
|
};
|
||||||
|
clonInterno?: {
|
||||||
|
facultadId?: string;
|
||||||
|
carreraId?: string;
|
||||||
|
planOrigenId?: string;
|
||||||
|
asignaturaOrigenId?: string | null;
|
||||||
|
};
|
||||||
|
clonTradicional?: {
|
||||||
|
archivoWordAsignaturaId: string | null;
|
||||||
|
archivosAdicionalesIds: Array<string>;
|
||||||
|
};
|
||||||
|
iaConfig?: {
|
||||||
|
descripcionEnfoque: string;
|
||||||
|
notasAdicionales: string;
|
||||||
|
archivosExistentesIds: Array<string>;
|
||||||
|
};
|
||||||
|
resumen: {
|
||||||
|
previewAsignatura?: AsignaturaPreview;
|
||||||
|
};
|
||||||
|
isLoading: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@ export function useNuevoPlanWizard() {
|
|||||||
clonTradicional: {
|
clonTradicional: {
|
||||||
archivoWordPlanId: null,
|
archivoWordPlanId: null,
|
||||||
archivoMapaExcelId: null,
|
archivoMapaExcelId: null,
|
||||||
archivoMateriasExcelId: null,
|
archivoAsignaturasExcelId: null,
|
||||||
},
|
},
|
||||||
iaConfig: {
|
iaConfig: {
|
||||||
descripcionEnfoque: "",
|
descripcionEnfoque: "",
|
||||||
@@ -62,7 +62,7 @@ export function useNuevoPlanWizard() {
|
|||||||
if (!t) return false;
|
if (!t) return false;
|
||||||
const tieneWord = !!t.archivoWordPlanId;
|
const tieneWord = !!t.archivoWordPlanId;
|
||||||
const tieneAlMenosUnExcel = !!t.archivoMapaExcelId ||
|
const tieneAlMenosUnExcel = !!t.archivoMapaExcelId ||
|
||||||
!!t.archivoMateriasExcelId;
|
!!t.archivoAsignaturasExcelId;
|
||||||
return tieneWord && tieneAlMenosUnExcel;
|
return tieneWord && tieneAlMenosUnExcel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ export function useNuevoPlanWizard() {
|
|||||||
nivel: wizard.datosBasicos.nivel || "Licenciatura",
|
nivel: wizard.datosBasicos.nivel || "Licenciatura",
|
||||||
tipoCiclo: wizard.datosBasicos.tipoCiclo,
|
tipoCiclo: wizard.datosBasicos.tipoCiclo,
|
||||||
numCiclos: wizard.datosBasicos.numCiclos,
|
numCiclos: wizard.datosBasicos.numCiclos,
|
||||||
numMateriasAprox: wizard.datosBasicos.numCiclos * 6,
|
numAsignaturasAprox: wizard.datosBasicos.numCiclos * 6,
|
||||||
secciones: [
|
secciones: [
|
||||||
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
|
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
|
||||||
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
|
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type PlanPreview = {
|
|||||||
nivel: string;
|
nivel: string;
|
||||||
tipoCiclo: TipoCiclo;
|
tipoCiclo: TipoCiclo;
|
||||||
numCiclos: number;
|
numCiclos: number;
|
||||||
numMateriasAprox?: number;
|
numAsignaturasAprox?: number;
|
||||||
secciones?: Array<{ id: string; titulo: string; resumen: string }>;
|
secciones?: Array<{ id: string; titulo: string; resumen: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export type NewPlanWizardState = {
|
|||||||
clonTradicional?: {
|
clonTradicional?: {
|
||||||
archivoWordPlanId: string | null;
|
archivoWordPlanId: string | null;
|
||||||
archivoMapaExcelId: string | null;
|
archivoMapaExcelId: string | null;
|
||||||
archivoMateriasExcelId: string | null;
|
archivoAsignaturasExcelId: string | null;
|
||||||
};
|
};
|
||||||
iaConfig?: {
|
iaConfig?: {
|
||||||
descripcionEnfoque: string;
|
descripcionEnfoque: string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user