Separación vista/lógica del wizard de creación de plan
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { defineStepper } from '@stepperize/react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
// import './App.css'
|
||||
|
||||
const { useStepper, steps, utils } = defineStepper(
|
||||
{
|
||||
id: 'shipping',
|
||||
title: 'Shipping',
|
||||
description: 'Enter your shipping details',
|
||||
},
|
||||
{
|
||||
id: 'payment',
|
||||
title: 'Payment',
|
||||
description: 'Enter your payment details',
|
||||
},
|
||||
{ id: 'complete', title: 'Complete', description: 'Checkout complete' },
|
||||
)
|
||||
|
||||
function App() {
|
||||
const stepper = useStepper()
|
||||
|
||||
const currentIndex = utils.getIndex(stepper.current.id)
|
||||
return (
|
||||
<div className="w-[450px] space-y-6 rounded-lg border p-6">
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-lg font-medium">Checkout</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Step {currentIndex + 1} of {steps.length}
|
||||
</span>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="Checkout Steps" className="group my-4">
|
||||
<ol
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
{stepper.all.map((step, index, array) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<li className="flex flex-shrink-0 items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
role="tab"
|
||||
variant={index <= currentIndex ? 'default' : 'secondary'}
|
||||
aria-current={
|
||||
stepper.current.id === step.id ? 'step' : undefined
|
||||
}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={steps.length}
|
||||
aria-selected={stepper.current.id === step.id}
|
||||
className="flex size-10 items-center justify-center rounded-full"
|
||||
onClick={() => stepper.goTo(step.id)}
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{step.title}</span>
|
||||
</li>
|
||||
{index < array.length - 1 && (
|
||||
<Separator
|
||||
className={`flex-1 ${
|
||||
index < currentIndex ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<div className="space-y-4">
|
||||
{stepper.switch({
|
||||
shipping: () => <ShippingComponent />,
|
||||
payment: () => <PaymentComponent />,
|
||||
complete: () => <CompleteComponent />,
|
||||
})}
|
||||
{!stepper.isLast ? (
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={stepper.prev}
|
||||
disabled={stepper.isFirst}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={stepper.next}>
|
||||
{stepper.isLast ? 'Complete' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={stepper.reset}>Reset</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ShippingComponent = () => {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="name" className="text-start text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<Input id="name" placeholder="John Doe" className="w-full" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="address" className="text-start text-sm font-medium">
|
||||
Address
|
||||
</label>
|
||||
<Textarea
|
||||
id="address"
|
||||
placeholder="123 Main St, Anytown USA"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PaymentComponent = () => {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="card-number" className="text-start text-sm font-medium">
|
||||
Card Number
|
||||
</label>
|
||||
<Input
|
||||
id="card-number"
|
||||
placeholder="4111 1111 1111 1111"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label
|
||||
htmlFor="expiry-date"
|
||||
className="text-start text-sm font-medium"
|
||||
>
|
||||
Expiry Date
|
||||
</label>
|
||||
<Input id="expiry-date" placeholder="MM/YY" className="w-full" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="cvc" className="text-start text-sm font-medium">
|
||||
CVC
|
||||
</label>
|
||||
<Input id="cvc" placeholder="123" className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CompleteComponent = () => {
|
||||
return <h3 className="py-4 text-lg font-medium">Stepper complete 🔥</h3>
|
||||
}
|
||||
|
||||
export default App
|
||||
181
src/components/planes/wizard/PasoBasicosForm.tsx
Normal file
181
src/components/planes/wizard/PasoBasicosForm.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { CARRERAS } from '@/features/planes/new/catalogs'
|
||||
import type { NewPlanWizardState } from '@/features/planes/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 {
|
||||
FACULTADES,
|
||||
NIVELES,
|
||||
TIPOS_CICLO,
|
||||
} from '@/features/planes/new/catalogs'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
carrerasFiltradas,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
carrerasFiltradas: typeof CARRERAS
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="nombrePlan">Nombre del plan</Label>
|
||||
<Input
|
||||
id="nombrePlan"
|
||||
placeholder="Ej. Ingeniería en Sistemas 2026"
|
||||
value={wizard.datosBasicos.nombrePlan}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="facultad">Facultad</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
facultadId: value,
|
||||
carreraId: '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="facultad"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona facultad…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACULTADES.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="carrera">Carrera</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
||||
}))
|
||||
}
|
||||
disabled={!wizard.datosBasicos.facultadId}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="carrera"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona carrera…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{carrerasFiltradas.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="nivel">Nivel</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.nivel}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="nivel"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona nivel…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NIVELES.map((n) => (
|
||||
<SelectItem key={n} value={n}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.tipoCiclo}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipoCiclo: value as any,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="tipoCiclo"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_CICLO.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="numCiclos">Número de ciclos</Label>
|
||||
<Input
|
||||
id="numCiclos"
|
||||
type="number"
|
||||
min={1}
|
||||
value={wizard.datosBasicos.numCiclos}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
numCiclos: Number(e.target.value || 1),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
322
src/components/planes/wizard/PasoDetallesPanel.tsx
Normal file
322
src/components/planes/wizard/PasoDetallesPanel.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { NewPlanWizardState } from '@/features/planes/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 {
|
||||
CARRERAS,
|
||||
FACULTADES,
|
||||
PLANES_EXISTENTES,
|
||||
} from '@/features/planes/new/catalogs'
|
||||
|
||||
export function PasoDetallesPanel({
|
||||
wizard,
|
||||
onChange,
|
||||
onGenerarIA,
|
||||
isLoading,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
onGenerarIA: () => void
|
||||
isLoading: boolean
|
||||
}) {
|
||||
if (wizard.modoCreacion === 'MANUAL') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Creación manual</CardTitle>
|
||||
<CardDescription>
|
||||
Se creará un plan en blanco con estructura mínima.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.modoCreacion === 'IA') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label htmlFor="desc">Descripción del enfoque</Label>
|
||||
<textarea
|
||||
id="desc"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="Describe el enfoque del programa…"
|
||||
value={wizard.iaConfig?.descripcionEnfoque || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
descripcionEnfoque: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="poblacion">Población objetivo</Label>
|
||||
<Input
|
||||
id="poblacion"
|
||||
placeholder="Ej. Egresados de bachillerato con perfil STEM"
|
||||
value={wizard.iaConfig?.poblacionObjetivo || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
poblacionObjetivo: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="notas">Notas adicionales</Label>
|
||||
<textarea
|
||||
id="notas"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="Lineamientos institucionales, restricciones, etc."
|
||||
value={wizard.iaConfig?.notasAdicionales || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
notasAdicionales: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Opcional: se pueden adjuntar recursos IA más adelante.
|
||||
</div>
|
||||
<Button onClick={onGenerarIA} disabled={isLoading}>
|
||||
{isLoading ? 'Generando…' : 'Generar borrador con IA'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{wizard.resumen.previewPlan && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview IA</CardTitle>
|
||||
<CardDescription>
|
||||
Materias aprox.: {wizard.resumen.previewPlan.numMateriasAprox}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-sm">
|
||||
{wizard.resumen.previewPlan.secciones?.map((s) => (
|
||||
<li key={s.id}>
|
||||
<span className="text-foreground font-medium">
|
||||
{s.titulo}:
|
||||
</span>{' '}
|
||||
{s.resumen}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO'
|
||||
) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor="clonFacultad">Facultad</Label>
|
||||
<select
|
||||
id="clonFacultad"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
facultadId: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
{FACULTADES.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="clonCarrera">Carrera</Label>
|
||||
<select
|
||||
id="clonCarrera"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
carreraId: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
{CARRERAS.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="buscarPlan">Buscar</Label>
|
||||
<Input
|
||||
id="buscarPlan"
|
||||
placeholder="Nombre del plan…"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const term = e.target.value.toLowerCase()
|
||||
void term
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{PLANES_EXISTENTES.filter(
|
||||
(p) =>
|
||||
(!wizard.datosBasicos.facultadId ||
|
||||
p.facultadId === wizard.datosBasicos.facultadId) &&
|
||||
(!wizard.datosBasicos.carreraId ||
|
||||
p.carreraId === wizard.datosBasicos.carreraId),
|
||||
).map((p) => (
|
||||
<Card
|
||||
key={p.id}
|
||||
className={
|
||||
p.id === wizard.clonInterno?.planOrigenId
|
||||
? 'ring-ring ring-2'
|
||||
: ''
|
||||
}
|
||||
onClick={() =>
|
||||
onChange((w) => ({ ...w, clonInterno: { planOrigenId: p.id } }))
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>{p.nombre}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{p.estado} · {p.anio}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>ID: {p.id}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL'
|
||||
) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<Label htmlFor="word">Word del plan (obligatorio)</Label>
|
||||
<input
|
||||
id="word"
|
||||
type="file"
|
||||
accept=".doc,.docx"
|
||||
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"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoWordPlanId: e.target.files?.[0]
|
||||
? `file_${e.target.files[0].name}`
|
||||
: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="mapa">Excel del mapa curricular</Label>
|
||||
<input
|
||||
id="mapa"
|
||||
type="file"
|
||||
accept=".xls,.xlsx"
|
||||
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"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoMapaExcelId: e.target.files?.[0]
|
||||
? `file_${e.target.files[0].name}`
|
||||
: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="materias">Excel/listado de materias</Label>
|
||||
<input
|
||||
id="materias"
|
||||
type="file"
|
||||
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"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoMateriasExcelId: e.target.files?.[0]
|
||||
? `file_${e.target.files[0].name}`
|
||||
: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Sube al menos Word y uno de los Excel para continuar.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Selecciona un modo</CardTitle>
|
||||
<CardDescription>
|
||||
Elige una opción en el paso anterior para continuar.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
122
src/components/planes/wizard/PasoModoCardGroup.tsx
Normal file
122
src/components/planes/wizard/PasoModoCardGroup.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type {
|
||||
NewPlanWizardState,
|
||||
ModoCreacion,
|
||||
SubModoClonado,
|
||||
} from '@/features/planes/new/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
export function PasoModoCardGroup({
|
||||
wizard,
|
||||
onChange,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
}) {
|
||||
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>Plan vacío con estructura mínima.</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>
|
||||
Borrador completo a partir de datos base.
|
||||
</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>Desde un plan existente o archivos.</CardDescription>
|
||||
</CardHeader>
|
||||
{wizard.modoCreacion === 'CLONADO' && (
|
||||
<CardContent 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 flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('INTERNO')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
} `}
|
||||
>
|
||||
<Icons.Database className="mb-1 h-6 w-6" />
|
||||
<span className="text-sm font-medium">Del sistema</span>
|
||||
</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 flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('TRADICIONAL')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
} `}
|
||||
>
|
||||
<Icons.Upload className="mb-1 h-6 w-6" />
|
||||
<span className="text-sm font-medium">Desde archivos</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
src/components/planes/wizard/PasoResumenCard.tsx
Normal file
74
src/components/planes/wizard/PasoResumenCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
const modo = wizard.modoCreacion
|
||||
const sub = wizard.subModoClonado
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resumen</CardTitle>
|
||||
<CardDescription>
|
||||
Verifica la información antes de crear.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Nombre: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.nombrePlan || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Facultad/Carrera: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.facultadId || '—'} /{' '}
|
||||
{wizard.datosBasicos.carreraId || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Nivel: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.nivel || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Ciclos: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.numCiclos} ({wizard.datosBasicos.tipoCiclo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">Modo: </span>
|
||||
<span className="font-medium">
|
||||
{modo === 'MANUAL' && 'Manual'}
|
||||
{modo === 'IA' && 'Generado con IA'}
|
||||
{modo === 'CLONADO' &&
|
||||
sub === 'INTERNO' &&
|
||||
'Clonado desde plan del sistema'}
|
||||
{modo === 'CLONADO' &&
|
||||
sub === 'TRADICIONAL' &&
|
||||
'Importado desde documentos tradicionales'}
|
||||
</span>
|
||||
</div>
|
||||
{wizard.resumen.previewPlan && (
|
||||
<div className="bg-muted mt-2 rounded-md p-3">
|
||||
<div className="font-medium">Preview IA</div>
|
||||
<div className="text-muted-foreground">
|
||||
Materias aprox.: {wizard.resumen.previewPlan.numMateriasAprox}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
41
src/components/planes/wizard/StepWithTooltip.tsx
Normal file
41
src/components/planes/wizard/StepWithTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
47
src/components/planes/wizard/WizardControls.tsx
Normal file
47
src/components/planes/wizard/WizardControls.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function WizardControls({
|
||||
errorMessage,
|
||||
onPrev,
|
||||
onNext,
|
||||
onCreate,
|
||||
disablePrev,
|
||||
disableNext,
|
||||
disableCreate,
|
||||
isLastStep,
|
||||
}: {
|
||||
errorMessage?: string | null
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onCreate: () => void
|
||||
disablePrev: boolean
|
||||
disableNext: boolean
|
||||
disableCreate: boolean
|
||||
isLastStep: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
{errorMessage && (
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
{errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
|
||||
Anterior
|
||||
</Button>
|
||||
{isLastStep ? (
|
||||
<Button onClick={onCreate} disabled={disableCreate}>
|
||||
Crear plan
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onNext} disabled={disableNext}>
|
||||
Siguiente
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/components/planes/wizard/WizardHeader.tsx
Normal file
78
src/components/planes/wizard/WizardHeader.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
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({
|
||||
currentIndex,
|
||||
totalSteps,
|
||||
currentTitle,
|
||||
currentDescription,
|
||||
nextTitle,
|
||||
onClose,
|
||||
Wizard,
|
||||
}: {
|
||||
currentIndex: number
|
||||
totalSteps: number
|
||||
currentTitle: string
|
||||
currentDescription: string
|
||||
nextTitle?: string
|
||||
onClose: () => void
|
||||
Wizard: any
|
||||
}) {
|
||||
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>Nuevo plan de estudios</DialogTitle>
|
||||
</DialogHeader>
|
||||
<button
|
||||
onClick={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={currentTitle}
|
||||
desc={currentDescription}
|
||||
/>
|
||||
</h2>
|
||||
{nextTitle ? (
|
||||
<p className="text-sm text-slate-400">Siguiente: {nextTitle}</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>
|
||||
)
|
||||
}
|
||||
@@ -57,15 +57,17 @@ const defineStepper = <const Steps extends Array<Stepperize.Step>>(
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
// Avoid leaking non-DOM props like `initialStep` onto the div
|
||||
const { initialStep, initialMetadata, ...restProps } = props as {
|
||||
initialStep?: any
|
||||
initialMetadata?: any
|
||||
} & Record<string, unknown>
|
||||
return (
|
||||
<StepperContext.Provider
|
||||
value={{ variant, labelOrientation, tracking }}
|
||||
>
|
||||
<Scoped
|
||||
initialStep={props.initialStep}
|
||||
initialMetadata={props.initialMetadata}
|
||||
>
|
||||
<StepperContainer className={className} {...props}>
|
||||
<Scoped initialStep={initialStep} initialMetadata={initialMetadata}>
|
||||
<StepperContainer className={className} {...(restProps as any)}>
|
||||
{children}
|
||||
</StepperContainer>
|
||||
</Scoped>
|
||||
|
||||
Reference in New Issue
Block a user