feat: integrate Radix UI Accordion component and enhance subject wizard
- Added Radix UI Accordion component for better UI organization in PasoDetallesPanel. - Implemented structure selection and subject suggestions management in the wizard. - Updated subject API to initialize new subjects with null values for structure and cycle. - Modified state management in useNuevaAsignaturaWizard to include estructuraId. - Adjusted types for suggested subjects to include line and cycle information.
This commit is contained in:
@@ -4,6 +4,12 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
@@ -20,6 +26,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
|
||||
import {
|
||||
FACULTADES,
|
||||
MATERIAS_MOCK,
|
||||
@@ -33,6 +40,10 @@ export function PasoDetallesPanel({
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const { data: estructurasAsignatura } = useSubjectEstructuras()
|
||||
const { data: plan } = usePlan(wizard.plan_estudio_id)
|
||||
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
|
||||
|
||||
if (wizard.tipoOrigen === 'MANUAL') {
|
||||
return (
|
||||
<Card>
|
||||
@@ -147,7 +158,168 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
|
||||
return <div>Hola</div>
|
||||
const maxCiclos = Math.max(1, plan?.numero_ciclos ?? 1)
|
||||
const sugerenciasSeleccionadas = wizard.sugerencias.filter(
|
||||
(s) => s.selected,
|
||||
)
|
||||
|
||||
const patchSugerencia = (
|
||||
id: string,
|
||||
patch: Partial<NewSubjectWizardState['sugerencias'][number]>,
|
||||
) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
sugerencias: w.sugerencias.map((s) =>
|
||||
s.id === id ? { ...s, ...patch } : s,
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Estructura de la asignatura
|
||||
</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.estructuraId ?? undefined}
|
||||
onValueChange={(val) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
estructuraId: val,
|
||||
datosBasicos: { ...w.datosBasicos, estructuraId: val },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona una estructura" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(estructurasAsignatura ?? []).map((e) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
|
||||
<h3 className="text-foreground mx-3 mb-2 text-lg font-semibold">
|
||||
Materias seleccionadas
|
||||
</h3>
|
||||
{sugerenciasSeleccionadas.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Selecciona al menos una sugerencia para configurar su descripción,
|
||||
línea curricular y ciclo.
|
||||
</div>
|
||||
) : (
|
||||
<Accordion type="multiple" className="w-full space-y-2">
|
||||
{sugerenciasSeleccionadas.map((asig) => (
|
||||
<AccordionItem
|
||||
key={asig.id}
|
||||
value={asig.id}
|
||||
className="border-border/60 bg-background/40 rounded-lg border border-b-0 px-3"
|
||||
>
|
||||
<AccordionTrigger className="hover:bg-accent/30 data-[state=open]:bg-accent/20 data-[state=open]:text-accent-foreground -mx-3 px-3">
|
||||
{asig.nombre}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
<div className="mx-1 grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Descripción
|
||||
</Label>
|
||||
<Textarea
|
||||
value={asig.descripcion}
|
||||
maxLength={7000}
|
||||
rows={6}
|
||||
onChange={(e) =>
|
||||
patchSugerencia(asig.id, {
|
||||
descripcion: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Ciclo (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxCiclos}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
placeholder={`1-${maxCiclos}`}
|
||||
value={asig.numero_ciclo ?? ''}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
['.', ',', '-', 'e', 'E', '+'].includes(e.key)
|
||||
) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') {
|
||||
patchSugerencia(asig.id, { numero_ciclo: null })
|
||||
return
|
||||
}
|
||||
|
||||
const asNumber = Number(raw)
|
||||
if (!Number.isFinite(asNumber)) return
|
||||
|
||||
const n = Math.floor(Math.abs(asNumber))
|
||||
const capped = Math.min(
|
||||
Math.max(n >= 1 ? n : 1, 1),
|
||||
maxCiclos,
|
||||
)
|
||||
|
||||
patchSugerencia(asig.id, { numero_ciclo: capped })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Línea curricular (opcional)
|
||||
</Label>
|
||||
<Select
|
||||
value={asig.linea_plan_id ?? '__none__'}
|
||||
onValueChange={(val) =>
|
||||
patchSugerencia(asig.id, {
|
||||
linea_plan_id: val === '__none__' ? null : val,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sin línea" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Ninguna</SelectItem>
|
||||
{(lineasPlan ?? []).map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||
|
||||
64
src/components/ui/accordion.tsx
Normal file
64
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -159,7 +159,6 @@ export async function generate_subject_suggestions(
|
||||
id: crypto.randomUUID(),
|
||||
selected: false,
|
||||
source: 'IA',
|
||||
estructuraId: null,
|
||||
nombre: s.nombre,
|
||||
codigo: s.codigo,
|
||||
tipo: s.tipo ?? null,
|
||||
@@ -167,6 +166,8 @@ export async function generate_subject_suggestions(
|
||||
horasAcademicas: s.horasAcademicas ?? null,
|
||||
horasIndependientes: s.horasIndependientes ?? null,
|
||||
descripcion: s.descripcion,
|
||||
linea_plan_id: null,
|
||||
numero_ciclo: null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export function useNuevaAsignaturaWizard(planId: string) {
|
||||
const [wizard, setWizard] = useState<NewSubjectWizardState>({
|
||||
step: 1,
|
||||
plan_estudio_id: planId,
|
||||
estructuraId: null,
|
||||
tipoOrigen: null,
|
||||
datosBasicos: {
|
||||
nombre: '',
|
||||
@@ -47,12 +48,11 @@ export function useNuevaAsignaturaWizard(planId: string) {
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
|
||||
|
||||
const canContinueDesdeBasicos =
|
||||
(!!wizard.datosBasicos.nombre &&
|
||||
wizard.datosBasicos.tipo !== null &&
|
||||
wizard.datosBasicos.creditos !== null &&
|
||||
wizard.datosBasicos.creditos > 0 &&
|
||||
!!wizard.datosBasicos.estructuraId) ||
|
||||
true
|
||||
!!wizard.datosBasicos.nombre &&
|
||||
wizard.datosBasicos.tipo !== null &&
|
||||
wizard.datosBasicos.creditos !== null &&
|
||||
wizard.datosBasicos.creditos > 0 &&
|
||||
!!wizard.datosBasicos.estructuraId
|
||||
|
||||
const canContinueDesdeDetalles = (() => {
|
||||
if (wizard.tipoOrigen === 'MANUAL') return true
|
||||
@@ -65,6 +65,9 @@ export function useNuevaAsignaturaWizard(planId: string) {
|
||||
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
||||
return !!wizard.clonTradicional?.archivoWordAsignaturaId
|
||||
}
|
||||
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
|
||||
return wizard.estructuraId !== null
|
||||
}
|
||||
return false
|
||||
})()
|
||||
|
||||
|
||||
@@ -25,12 +25,14 @@ export type AsignaturaSugerida = {
|
||||
id: string
|
||||
selected: boolean
|
||||
source: 'IA' | 'MANUAL' | 'CLON'
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
linea_plan_id: string | null
|
||||
numero_ciclo: number | null
|
||||
} & DataAsignaturaSugerida
|
||||
|
||||
export type NewSubjectWizardState = {
|
||||
step: 1 | 2 | 3 | 4
|
||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
tipoOrigen:
|
||||
| Asignatura['tipo_origen']
|
||||
| 'CLONADO'
|
||||
|
||||
Reference in New Issue
Block a user