Compare commits
3 Commits
69119aeaa6
...
b9c809e648
| Author | SHA1 | Date | |
|---|---|---|---|
| b9c809e648 | |||
| cddc676f7d | |||
| edae79c255 |
9
bun.lock
9
bun.lock
@@ -46,6 +46,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
@@ -770,6 +771,8 @@
|
||||
|
||||
"eslint-plugin-n": ["eslint-plugin-n@17.23.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||
|
||||
"eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.3.0", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
@@ -864,6 +867,10 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
@@ -1318,6 +1325,8 @@
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
|
||||
export default [
|
||||
@@ -24,9 +25,12 @@ export default [
|
||||
|
||||
// 3. TUS REGLAS Y CONFIGURACIÓN "PRO"
|
||||
{
|
||||
// Opcional: Puedes ser explícito sobre dónde aplicar esto
|
||||
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||
plugins: {
|
||||
'jsx-a11y': jsxA11y,
|
||||
'unused-imports': unusedImports,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
// Configuración robusta del Resolver (La versión de Copilot)
|
||||
settings: {
|
||||
@@ -44,7 +48,8 @@ export default [
|
||||
// --- REGLAS DE ACCESIBILIDAD (A11Y) ---
|
||||
// Activamos las recomendadas manualmente
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
// --- ORDEN DE IMPORTS ---
|
||||
'sort-imports': 'off', // Apagamos el nativo
|
||||
'import/order': [
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { CARRERAS } from '@/features/planes/nuevo/catalogs'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/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/nuevo/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 placeholder="Selecciona tipo de ciclo…" />
|
||||
</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),
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
253
src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx
Normal file
253
src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { TemplateSelectorCard } from './TemplateSelectorCard'
|
||||
|
||||
import type { CARRERAS } from '@/features/planes/nuevo/catalogs'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
FACULTADES,
|
||||
NIVELES,
|
||||
TIPOS_CICLO,
|
||||
PLANTILLAS_ANEXO_1,
|
||||
PLANTILLAS_ANEXO_2,
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
carrerasFiltradas,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
carrerasFiltradas: typeof CARRERAS
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="nombrePlan">
|
||||
Nombre del plan <span className="text-destructive">*</span>
|
||||
</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 },
|
||||
}))
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
/>
|
||||
</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={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.facultadId
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
|
||||
</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={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.carreraId
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
|
||||
</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={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.nivel
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Licenciatura" />
|
||||
</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={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.tipoCiclo
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Semestre" />
|
||||
</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,
|
||||
// Keep undefined when the input is empty so the field stays optional
|
||||
numCiclos:
|
||||
e.target.value === '' ? undefined : Number(e.target.value),
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
placeholder="Ej. 8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TemplateSelectorCard
|
||||
cardTitle="Plantilla de plan de estudios"
|
||||
cardDescription="Selecciona el Word para tu nuevo plan."
|
||||
templatesData={PLANTILLAS_ANEXO_1}
|
||||
selectedTemplateId={wizard.datosBasicos.plantillaPlanId || ''}
|
||||
selectedVersion={wizard.datosBasicos.plantillaPlanVersion || ''}
|
||||
onChange={({ templateId, version }) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
plantillaPlanId: templateId,
|
||||
plantillaPlanVersion: version,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<TemplateSelectorCard
|
||||
cardTitle="Plantilla de mapa curricular"
|
||||
cardDescription="Selecciona el Excel para tu mapa curricular."
|
||||
templatesData={PLANTILLAS_ANEXO_2}
|
||||
selectedTemplateId={wizard.datosBasicos.plantillaMapaId || ''}
|
||||
selectedVersion={wizard.datosBasicos.plantillaMapaVersion || ''}
|
||||
onChange={({ templateId, version }) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
plantillaMapaId: templateId,
|
||||
plantillaMapaVersion: version,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type TemplateData = {
|
||||
id: string
|
||||
name: string
|
||||
versions: Array<string>
|
||||
}
|
||||
|
||||
// Default data (kept for backward compatibility if caller doesn't pass templates)
|
||||
const DEFAULT_TEMPLATES_DATA: Array<TemplateData> = [
|
||||
{
|
||||
id: 'sep-2025',
|
||||
name: 'Licenciatura RVOE SEP',
|
||||
versions: ['v2025.2 (Vigente)', 'v2025.1', 'v2024.Final'],
|
||||
},
|
||||
{
|
||||
id: 'interno-mix',
|
||||
name: 'Estándar Institucional Mixto',
|
||||
versions: ['v2.0', 'v1.5', 'v1.0-beta'],
|
||||
},
|
||||
{
|
||||
id: 'conacyt',
|
||||
name: 'Formato Posgrado CONAHCYT',
|
||||
versions: ['v3.0 (2025)', 'v2.8'],
|
||||
},
|
||||
]
|
||||
|
||||
interface Props {
|
||||
cardTitle?: string
|
||||
cardDescription?: string
|
||||
templatesData?: Array<TemplateData>
|
||||
// Controlled selection (optional). If not provided, component manages its own state
|
||||
selectedTemplateId?: string
|
||||
selectedVersion?: string
|
||||
onChange?: (sel: { templateId: string; version: string }) => void
|
||||
}
|
||||
|
||||
export function TemplateSelectorCard({
|
||||
cardTitle = 'Configuración del Documento',
|
||||
cardDescription = 'Selecciona la base para tu nuevo plan.',
|
||||
templatesData = DEFAULT_TEMPLATES_DATA,
|
||||
selectedTemplateId,
|
||||
selectedVersion,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [internalTemplate, setInternalTemplate] = useState<string>('')
|
||||
const [internalVersion, setInternalVersion] = useState<string>('')
|
||||
|
||||
const selectedTemplate = selectedTemplateId ?? internalTemplate
|
||||
const version = selectedVersion ?? internalVersion
|
||||
|
||||
// Buscamos las versiones de la plantilla seleccionada
|
||||
const currentTemplateData = useMemo(
|
||||
() => templatesData.find((t) => t.id === selectedTemplate),
|
||||
[templatesData, selectedTemplate],
|
||||
)
|
||||
const availableVersions = currentTemplateData?.versions || []
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
const template = templatesData.find((t) => t.id === value)
|
||||
const firstVersion = template?.versions?.[0] ?? ''
|
||||
if (onChange) {
|
||||
onChange({ templateId: value, version: firstVersion })
|
||||
} else {
|
||||
setInternalTemplate(value)
|
||||
setInternalVersion(firstVersion)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVersionChange = (value: string) => {
|
||||
if (onChange) {
|
||||
onChange({ templateId: selectedTemplate, version: value })
|
||||
} else {
|
||||
setInternalVersion(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-lg gap-2 overflow-hidden">
|
||||
<CardHeader className="px-4 pb-2 sm:px-6 sm:pb-4">
|
||||
<CardTitle className="text-lg">{cardTitle}</CardTitle>
|
||||
<CardDescription>{cardDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{/* SELECT 1: PRIMARIO (Llamativo) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="template-select"
|
||||
className="text-foreground text-base font-semibold"
|
||||
>
|
||||
Plantilla
|
||||
</Label>
|
||||
<Select value={selectedTemplate} onValueChange={handleTemplateChange}>
|
||||
<SelectTrigger
|
||||
id="template-select"
|
||||
className="bg-background border-primary/40 focus:ring-primary/20 focus:border-primary flex h-11 w-full min-w-0 items-center justify-between gap-2 text-base shadow-sm [&>span]:block! [&>span]:truncate! [&>span]:text-left"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona una plantilla..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templatesData.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id} className="font-medium">
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* SELECT 2: SECUNDARIO (Sutil) */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="version-select"
|
||||
className={cn(
|
||||
'text-xs tracking-wider uppercase transition-colors',
|
||||
!selectedTemplate
|
||||
? 'text-muted-foreground/50'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
Versión
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={version}
|
||||
onValueChange={handleVersionChange}
|
||||
disabled={!selectedTemplate}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="version-select"
|
||||
className={cn(
|
||||
'flex h-9 min-w-0 items-center justify-between gap-2 text-sm transition-all duration-300',
|
||||
/* AQUÍ ESTÁ EL CAMBIO DE ANCHO: */
|
||||
'w-full max-w-full sm:w-55',
|
||||
|
||||
/* Las correcciones vitales para truncado que ya teníamos: */
|
||||
'min-w-0 [&>span]:block! [&>span]:truncate! [&>span]:text-left',
|
||||
'[&>span]:block [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left',
|
||||
|
||||
!selectedTemplate
|
||||
? 'bg-muted/50 cursor-not-allowed border-transparent opacity-50'
|
||||
: 'bg-muted/20 border-border hover:bg-background hover:border-primary/30',
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!selectedTemplate
|
||||
? '— Esperando plantilla —'
|
||||
: 'Selecciona versión'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVersions.map((v) => (
|
||||
<SelectItem
|
||||
key={v}
|
||||
value={v}
|
||||
className="text-muted-foreground focus:text-foreground text-sm"
|
||||
>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Upload, File, X, FileText } from 'lucide-react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -20,7 +20,7 @@ interface FileDropzoneProps {
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
// onFilesChange,
|
||||
onFilesChange,
|
||||
acceptedTypes = '.doc,.docx,.pdf',
|
||||
maxFiles = 5,
|
||||
title = 'Arrastra archivos aquí',
|
||||
@@ -28,6 +28,27 @@ export function FileDropzone({
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [files, setFiles] = useState<Array<UploadedFile>>([])
|
||||
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
|
||||
|
||||
const addFiles = useCallback(
|
||||
(newFiles: Array<File>) => {
|
||||
const toUpload: Array<UploadedFile> = newFiles.map((file) => ({
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}))
|
||||
setFiles((prev) => {
|
||||
const room = Math.max(0, maxFiles - prev.length)
|
||||
const next = [...prev, ...toUpload.slice(0, room)].slice(0, maxFiles)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[maxFiles],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -43,11 +64,10 @@ export function FileDropzone({
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||
addFiles(droppedFiles)
|
||||
},
|
||||
[files],
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
@@ -57,29 +77,25 @@ export function FileDropzone({
|
||||
addFiles(selectedFiles)
|
||||
}
|
||||
},
|
||||
[files],
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
const addFiles = (newFiles: Array<File>) => {
|
||||
const uploadedFiles: Array<UploadedFile> = newFiles
|
||||
.slice(0, maxFiles - files.length)
|
||||
.map((file) => ({
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}))
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
setFiles((prev) => {
|
||||
const next = prev.filter((f) => f.id !== fileId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updatedFiles = [...files, ...uploadedFiles].slice(0, maxFiles)
|
||||
setFiles(updatedFiles)
|
||||
// onFilesChange(updatedFiles)
|
||||
}
|
||||
// Keep latest callback in a ref to avoid retriggering effect on identity change
|
||||
useEffect(() => {
|
||||
onFilesChangeRef.current = onFilesChange
|
||||
}, [onFilesChange])
|
||||
|
||||
const removeFile = (fileId: string) => {
|
||||
const updatedFiles = files.filter((f) => f.id !== fileId)
|
||||
setFiles(updatedFiles)
|
||||
// onFilesChange(updatedFiles)
|
||||
}
|
||||
// Only emit when files actually change to avoid parent update loops
|
||||
useEffect(() => {
|
||||
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
|
||||
}, [files])
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
@@ -109,7 +125,6 @@ export function FileDropzone({
|
||||
'border-border hover:border-primary/50 cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300',
|
||||
isDragging && 'active',
|
||||
)}
|
||||
style={{ background: 'var(--gradient-subtle)' }}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -119,7 +134,11 @@ export function FileDropzone({
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload" className="cursor-pointer">
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="cursor-pointer"
|
||||
aria-label="Seleccionar archivos"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ReferenciasParaIA from './PasoDetallesPanel/ReferenciasParaIA'
|
||||
import { FileDropzone } from './FileDropZone'
|
||||
import ReferenciasParaIA from './ReferenciasParaIA'
|
||||
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
@@ -82,7 +83,49 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ReferenciasParaIA />
|
||||
<ReferenciasParaIA
|
||||
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
|
||||
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
||||
onToggleArchivo={(id, checked) =>
|
||||
onChange((w) => {
|
||||
const prev = w.iaConfig?.archivosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
: prev.filter((x) => x !== id)
|
||||
return {
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
archivosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onToggleRepositorio={(id, checked) =>
|
||||
onChange((w) => {
|
||||
const prev = w.iaConfig?.repositoriosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
: prev.filter((x) => x !== id)
|
||||
return {
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
repositoriosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onFilesChange={(files) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
archivosAdjuntos: files,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Opcional: se pueden adjuntar recursos IA más adelante.
|
||||
@@ -131,6 +174,7 @@ export function PasoDetallesPanel({
|
||||
<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"
|
||||
aria-label="Facultad"
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange((w) => ({
|
||||
@@ -155,6 +199,7 @@ export function PasoDetallesPanel({
|
||||
<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"
|
||||
aria-label="Carrera"
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange((w) => ({
|
||||
@@ -229,10 +274,10 @@ export function PasoDetallesPanel({
|
||||
wizard.subModoClonado === 'TRADICIONAL'
|
||||
) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="word">Word del plan (obligatorio)</Label>
|
||||
<input
|
||||
{/* <input
|
||||
id="word"
|
||||
type="file"
|
||||
accept=".doc,.docx"
|
||||
@@ -248,6 +293,21 @@ export function PasoDetallesPanel({
|
||||
},
|
||||
}))
|
||||
}
|
||||
/> */}
|
||||
|
||||
<FileDropzone
|
||||
acceptedTypes=".doc,.docx"
|
||||
maxFiles={1}
|
||||
onFilesChange={(files) => {
|
||||
const f = files[0] || null
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoWordPlanId: f,
|
||||
},
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -256,17 +316,32 @@ export function PasoDetallesPanel({
|
||||
id="mapa"
|
||||
type="file"
|
||||
accept=".xls,.xlsx"
|
||||
title="Subir mapa curricular"
|
||||
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,
|
||||
},
|
||||
}))
|
||||
onChange((w) => {
|
||||
const file = e.target.files?.[0] || null
|
||||
const next = file
|
||||
? {
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}
|
||||
: null
|
||||
return {
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoMapaExcelId: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -276,17 +351,32 @@ export function PasoDetallesPanel({
|
||||
id="asignaturas"
|
||||
type="file"
|
||||
accept=".xls,.xlsx,.csv"
|
||||
title="Subir listado de asignaturas"
|
||||
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)),
|
||||
archivoAsignaturasExcelId: e.target.files?.[0]
|
||||
? `file_${e.target.files[0].name}`
|
||||
: null,
|
||||
},
|
||||
}))
|
||||
onChange((w) => {
|
||||
const file = e.target.files?.[0] || null
|
||||
const next = file
|
||||
? {
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}
|
||||
: null
|
||||
return {
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoAsignaturasExcelId: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -308,3 +398,9 @@ export function PasoDetallesPanel({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { FileText, FolderOpen, Upload } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import BarraBusqueda from '../../BarraBusqueda'
|
||||
|
||||
import { FileDropzone } from './FileDropZone'
|
||||
|
||||
@@ -13,93 +16,162 @@ import {
|
||||
} from '@/components/ui/motion-tabs'
|
||||
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Archivos existentes',
|
||||
const ReferenciasParaIA = ({
|
||||
selectedArchivoIds = [],
|
||||
selectedRepositorioIds = [],
|
||||
onToggleArchivo,
|
||||
onToggleRepositorio,
|
||||
onFilesChange,
|
||||
}: {
|
||||
selectedArchivoIds?: Array<string>
|
||||
selectedRepositorioIds?: Array<string>
|
||||
onToggleArchivo?: (id: string, checked: boolean) => void
|
||||
onToggleRepositorio?: (id: string, checked: boolean) => void
|
||||
onFilesChange?: (
|
||||
files: Array<{ id: string; name: string; size: string; type: string }>,
|
||||
) => void
|
||||
}) => {
|
||||
const [busquedaArchivos, setBusquedaArchivos] = useState('')
|
||||
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
|
||||
|
||||
value: 'archivos-existentes',
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD') // Descompone "á" en "a" + "´"
|
||||
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
||||
.toLowerCase() // Convierte a minúsculas
|
||||
}
|
||||
|
||||
icon: FileText,
|
||||
// Filtrado de archivos y de repositorios
|
||||
const archivosFiltrados = useMemo(() => {
|
||||
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
||||
|
||||
content: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{ARCHIVOS.map((archivo) => (
|
||||
<Label
|
||||
key={archivo.id}
|
||||
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950"
|
||||
>
|
||||
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
|
||||
const term = cleanText(busquedaArchivos)
|
||||
return ARCHIVOS.filter((archivo) =>
|
||||
cleanText(archivo.nombre).includes(term),
|
||||
)
|
||||
}, [busquedaArchivos])
|
||||
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
const repositoriosFiltrados = useMemo(() => {
|
||||
const term = cleanText(busquedaRepositorios)
|
||||
return REPOSITORIOS.filter((repositorio) =>
|
||||
cleanText(repositorio.nombre).includes(term),
|
||||
)
|
||||
}, [busquedaRepositorios])
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{archivo.nombre}
|
||||
</p>
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Archivos existentes',
|
||||
|
||||
<p className="text-muted-foreground text-xs">{archivo.tamaño}</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
value: 'archivos-existentes',
|
||||
|
||||
{
|
||||
name: 'Repositorios',
|
||||
icon: FileText,
|
||||
|
||||
value: 'repositorios',
|
||||
content: (
|
||||
<div className="flex flex-col">
|
||||
<BarraBusqueda
|
||||
value={busquedaArchivos}
|
||||
onChange={setBusquedaArchivos}
|
||||
placeholder="Buscar archivo existente..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
{archivosFiltrados.map((archivo) => (
|
||||
<Label
|
||||
key={archivo.id}
|
||||
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedArchivoIds.includes(archivo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleArchivo?.(archivo.id, !!checked)
|
||||
}
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
|
||||
icon: FolderOpen,
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
|
||||
content: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{REPOSITORIOS.map((repositorio) => (
|
||||
<Label
|
||||
key={repositorio.id}
|
||||
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950"
|
||||
>
|
||||
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{archivo.nombre}
|
||||
</p>
|
||||
|
||||
<FolderOpen className="text-muted-foreground h-4 w-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{repositorio.nombre}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{archivo.tamaño}
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{repositorio.descripcion} · {repositorio.cantidadArchivos}{' '}
|
||||
archivos
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Repositorios',
|
||||
|
||||
{
|
||||
name: 'Subir archivos',
|
||||
value: 'repositorios',
|
||||
|
||||
value: 'subir-archivos',
|
||||
icon: FolderOpen,
|
||||
|
||||
icon: Upload,
|
||||
content: (
|
||||
<div className="flex flex-col">
|
||||
<BarraBusqueda
|
||||
value={busquedaRepositorios}
|
||||
onChange={setBusquedaRepositorios}
|
||||
placeholder="Buscar repositorio..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
{repositoriosFiltrados.map((repositorio) => (
|
||||
<Label
|
||||
key={repositorio.id}
|
||||
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRepositorioIds.includes(repositorio.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleRepositorio?.(repositorio.id, !!checked)
|
||||
}
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
|
||||
content: (
|
||||
<div>
|
||||
<FileDropzone
|
||||
// onFilesChange={(files) =>
|
||||
// handleChange("archivosAdhocIds", files.map((f) => f.id))
|
||||
// }
|
||||
title="Sube archivos de referencia"
|
||||
description="Documentos que serán usados como contexto para la generación"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
<FolderOpen className="text-muted-foreground h-4 w-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{repositorio.nombre}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{repositorio.descripcion} · {repositorio.cantidadArchivos}{' '}
|
||||
archivos
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Subir archivos',
|
||||
|
||||
value: 'subir-archivos',
|
||||
|
||||
icon: Upload,
|
||||
|
||||
content: (
|
||||
<div>
|
||||
<FileDropzone
|
||||
onFilesChange={onFilesChange}
|
||||
title="Sube archivos de referencia"
|
||||
description="Documentos que serán usados como contexto para la generación"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const ReferenciasParaIA = () => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Label>Referencias para la IA</Label>
|
||||
|
||||
@@ -7,10 +7,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
PLANTILLAS_ANEXO_1,
|
||||
PLANTILLAS_ANEXO_2,
|
||||
PLANES_EXISTENTES,
|
||||
ARCHIVOS,
|
||||
REPOSITORIOS,
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
const modo = wizard.modoCreacion
|
||||
const sub = wizard.subModoClonado
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -21,53 +26,210 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
</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">
|
||||
Asignaturas aprox.:{' '}
|
||||
{wizard.resumen.previewPlan.numAsignaturasAprox}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
// Precompute common derived values to avoid unnecessary optional chaining warnings
|
||||
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
|
||||
const repositoriosRef =
|
||||
wizard.iaConfig?.repositoriosReferencia ?? []
|
||||
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
|
||||
const plantillaPlan = PLANTILLAS_ANEXO_1.find(
|
||||
(x) => x.id === wizard.datosBasicos.plantillaPlanId,
|
||||
)
|
||||
const plantillaMapa = PLANTILLAS_ANEXO_2.find(
|
||||
(x) => x.id === wizard.datosBasicos.plantillaMapaId,
|
||||
)
|
||||
const contenido = (
|
||||
<>
|
||||
<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">
|
||||
Plantilla plan:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaPlan?.name ||
|
||||
wizard.datosBasicos.plantillaPlanId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaPlanVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Mapa curricular:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaMapa?.name ||
|
||||
wizard.datosBasicos.plantillaMapaId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaMapaVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">Modo: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.modoCreacion === 'MANUAL' && 'Manual'}
|
||||
{wizard.modoCreacion === 'IA' && 'Generado con IA'}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' &&
|
||||
'Clonado desde plan del sistema'}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL' &&
|
||||
'Importado desde documentos tradicionales'}
|
||||
</span>
|
||||
</div>
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' && (
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
Plan origen:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(() => {
|
||||
const p = PLANES_EXISTENTES.find(
|
||||
(x) => x.id === wizard.clonInterno?.planOrigenId,
|
||||
)
|
||||
return (
|
||||
p?.nombre || wizard.clonInterno?.planOrigenId || '—'
|
||||
)
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL' && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Documentos adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Word del plan:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoWordPlanId?.name ||
|
||||
'—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Mapa curricular:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoMapaExcelId?.name ||
|
||||
'—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">Asignaturas:</span>{' '}
|
||||
{wizard.clonTradicional?.archivoAsignaturasExcelId
|
||||
?.name || '—'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'IA' && (
|
||||
<div className="bg-muted/50 mt-2 rounded-md p-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Enfoque: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.descripcionEnfoque || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Notas: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.notasAdicionales || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{archivosRef.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Archivos existentes</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{archivosRef.map((id) => {
|
||||
const a = ARCHIVOS.find((x) => x.id === id)
|
||||
return (
|
||||
<li key={id}>
|
||||
<span className="text-foreground">
|
||||
{a?.nombre || id}
|
||||
</span>{' '}
|
||||
{a?.tamaño ? <span>· {a.tamaño}</span> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{repositoriosRef.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Repositorios</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{repositoriosRef.map((id) => {
|
||||
const r = REPOSITORIOS.find((x) => x.id === id)
|
||||
return (
|
||||
<li key={id}>
|
||||
<span className="text-foreground">
|
||||
{r?.nombre || id}
|
||||
</span>{' '}
|
||||
{r?.cantidadArchivos ? (
|
||||
<span>· {r.cantidadArchivos} archivos</span>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{adjuntos.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{adjuntos.map((f) => (
|
||||
<li key={f.id}>
|
||||
<span className="text-foreground">{f.name}</span>{' '}
|
||||
<span>· {f.size}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
Asignaturas aprox.:{' '}
|
||||
{wizard.resumen.previewPlan.numAsignaturasAprox}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return contenido
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -3,8 +3,8 @@ import * as Icons from 'lucide-react'
|
||||
|
||||
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
|
||||
|
||||
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm'
|
||||
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel'
|
||||
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel'
|
||||
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
|
||||
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
|
||||
import { WizardControls } from '@/components/planes/wizard/WizardControls'
|
||||
|
||||
@@ -114,3 +114,39 @@ export const REPOSITORIOS = [
|
||||
cantidadArchivos: 23,
|
||||
},
|
||||
];
|
||||
|
||||
export const PLANTILLAS_ANEXO_1 = [
|
||||
{
|
||||
id: "sep-2025",
|
||||
name: "Licenciatura RVOE SEP.docx",
|
||||
versions: ["v2025.2 (Vigente)", "v2025.1", "v2024.Final"],
|
||||
},
|
||||
{
|
||||
id: "interno-mix",
|
||||
name: "Estándar Institucional Mixto.docx",
|
||||
versions: ["v2.0", "v1.5", "v1.0-beta"],
|
||||
},
|
||||
{
|
||||
id: "conacyt",
|
||||
name: "Formato Posgrado CONAHCYT.docx",
|
||||
versions: ["v3.0 (2025)", "v2.8"],
|
||||
},
|
||||
];
|
||||
|
||||
export const PLANTILLAS_ANEXO_2 = [
|
||||
{
|
||||
id: "sep-2017-xlsx",
|
||||
name: "Licenciatura RVOE 2017.xlsx",
|
||||
versions: ["v2017.0", "v2018.1", "v2019.2", "v2020.Final"],
|
||||
},
|
||||
{
|
||||
id: "interno-mix-xlsx",
|
||||
name: "Estándar Institucional Mixto.xlsx",
|
||||
versions: ["v1.0", "v1.5"],
|
||||
},
|
||||
{
|
||||
id: "conacyt-xlsx",
|
||||
name: "Formato Posgrado CONAHCYT.xlsx",
|
||||
versions: ["v1.0", "v2.0"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,6 +8,18 @@ export function useNuevoPlanWizard() {
|
||||
const [wizard, setWizard] = useState<NewPlanWizardState>({
|
||||
step: 1,
|
||||
modoCreacion: null,
|
||||
// datosBasicos: {
|
||||
// nombrePlan: "",
|
||||
// carreraId: "",
|
||||
// facultadId: "",
|
||||
// nivel: "",
|
||||
// tipoCiclo: "",
|
||||
// numCiclos: undefined,
|
||||
// plantillaPlanId: "",
|
||||
// plantillaPlanVersion: "",
|
||||
// plantillaMapaId: "",
|
||||
// plantillaMapaVersion: "",
|
||||
// },
|
||||
datosBasicos: {
|
||||
nombrePlan: "Medicina",
|
||||
carreraId: "medico",
|
||||
@@ -15,6 +27,10 @@ export function useNuevoPlanWizard() {
|
||||
nivel: "Licenciatura",
|
||||
tipoCiclo: "SEMESTRE",
|
||||
numCiclos: 8,
|
||||
plantillaPlanId: "sep-2025",
|
||||
plantillaPlanVersion: "v2025.2 (Vigente)",
|
||||
plantillaMapaId: "sep-2017-xlsx",
|
||||
plantillaMapaVersion: "v2017.0",
|
||||
},
|
||||
clonInterno: { planOrigenId: null },
|
||||
clonTradicional: {
|
||||
@@ -27,6 +43,8 @@ export function useNuevoPlanWizard() {
|
||||
poblacionObjetivo: "",
|
||||
notasAdicionales: "",
|
||||
archivosReferencia: [],
|
||||
repositoriosReferencia: [],
|
||||
archivosAdjuntos: [],
|
||||
},
|
||||
resumen: {},
|
||||
isLoading: false,
|
||||
@@ -47,12 +65,19 @@ export function useNuevoPlanWizard() {
|
||||
!!wizard.datosBasicos.facultadId &&
|
||||
!!wizard.datosBasicos.nivel &&
|
||||
(wizard.datosBasicos.numCiclos !== undefined &&
|
||||
wizard.datosBasicos.numCiclos > 0);
|
||||
wizard.datosBasicos.numCiclos > 0) &&
|
||||
// Requerir ambas plantillas (plan y mapa) con versión
|
||||
!!wizard.datosBasicos.plantillaPlanId &&
|
||||
!!wizard.datosBasicos.plantillaPlanVersion &&
|
||||
!!wizard.datosBasicos.plantillaMapaId &&
|
||||
!!wizard.datosBasicos.plantillaMapaVersion;
|
||||
|
||||
const canContinueDesdeDetalles = (() => {
|
||||
if (wizard.modoCreacion === "MANUAL") return true;
|
||||
if (wizard.modoCreacion === "IA") {
|
||||
return !!wizard.iaConfig?.descripcionEnfoque;
|
||||
// Requerimos descripción del enfoque y notas adicionales
|
||||
return !!wizard.iaConfig?.descripcionEnfoque &&
|
||||
!!wizard.iaConfig?.notasAdicionales;
|
||||
}
|
||||
if (wizard.modoCreacion === "CLONADO") {
|
||||
if (wizard.subModoClonado === "INTERNO") {
|
||||
|
||||
@@ -22,18 +22,44 @@ export type NewPlanWizardState = {
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo | "";
|
||||
numCiclos: number | undefined;
|
||||
// Selección de plantillas (obligatorias)
|
||||
plantillaPlanId?: string;
|
||||
plantillaPlanVersion?: string;
|
||||
plantillaMapaId?: string;
|
||||
plantillaMapaVersion?: string;
|
||||
};
|
||||
clonInterno?: { planOrigenId: string | null };
|
||||
clonTradicional?: {
|
||||
archivoWordPlanId: string | null;
|
||||
archivoMapaExcelId: string | null;
|
||||
archivoAsignaturasExcelId: string | null;
|
||||
archivoWordPlanId:
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
}
|
||||
| null;
|
||||
archivoMapaExcelId: {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
} | null;
|
||||
archivoAsignaturasExcelId: {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
} | null;
|
||||
};
|
||||
iaConfig?: {
|
||||
descripcionEnfoque: string;
|
||||
poblacionObjetivo: string;
|
||||
notasAdicionales: string;
|
||||
archivosReferencia: Array<string>;
|
||||
repositoriosReferencia?: Array<string>;
|
||||
archivosAdjuntos?: Array<
|
||||
{ id: string; name: string; size: string; type: string }
|
||||
>;
|
||||
};
|
||||
resumen: { previewPlan?: PlanPreview };
|
||||
isLoading: boolean;
|
||||
|
||||
@@ -183,10 +183,19 @@ function RouteComponent() {
|
||||
|
||||
// Filtrado de planes
|
||||
const filteredPlans = useMemo(() => {
|
||||
const term = search.trim().toLowerCase()
|
||||
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD') // Descompone "á" en "a" + "´"
|
||||
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
||||
.toLowerCase() // Convierte a minúsculas
|
||||
}
|
||||
// Limpiamos el término de búsqueda una sola vez antes de filtrar
|
||||
const term = cleanText(search.trim())
|
||||
return planes.filter((p) => {
|
||||
const matchName = term
|
||||
? p.nombrePrograma.toLowerCase().includes(term)
|
||||
? // Limpiamos también el nombre del programa antes de comparar
|
||||
cleanText(p.nombrePrograma).includes(term)
|
||||
: true
|
||||
const matchFac =
|
||||
facultadSel === 'todas' ? true : p.facultadId === facultadSel
|
||||
|
||||
Reference in New Issue
Block a user