Merge remote-tracking branch 'origin/feat/wizard-plan-vista' into feature/IntegrarDetallePlan

This commit is contained in:
2026-01-16 07:42:51 -06:00
42 changed files with 2127 additions and 847 deletions

View File

@@ -1,7 +1,7 @@
FROM oven/bun:1 AS build FROM oven/bun:1 AS build
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN bun install --frozen-lockfile RUN bun install
RUN bunx --bun vite build RUN bunx --bun vite build
FROM nginx:alpine FROM nginx:alpine

View File

@@ -124,6 +124,14 @@ export default [
}, },
}, },
// 5. PRETTIER AL FINAL // 5. OVERRIDE: desactivar reglas para tipos generados por supabase
{
files: ['src/types/supabase.ts'],
rules: {
'@typescript-eslint/naming-convention': 'off',
},
},
// 6. PRETTIER AL FINAL
eslintConfigPrettier, eslintConfigPrettier,
] ]

View File

@@ -19,8 +19,8 @@
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -50,13 +50,15 @@
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6" "tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/devtools-vite": "^0.3.11", "@tanstack/devtools-vite": "^0.3.11",
"@tanstack/eslint-config": "^0.3.0", "@tanstack/eslint-config": "^0.3.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/bun": "^1.3.6",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
@@ -70,6 +72,7 @@
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"supabase": "^2.72.2",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vitest": "^3.0.5", "vitest": "^3.0.5",

19
scripts/update-types.ts Normal file
View File

@@ -0,0 +1,19 @@
// scripts/update-types.ts
/* Uso:
bun run scripts/update-types.ts
*/
import { $ } from "bun";
console.log("🔄 Generando tipos de Supabase...");
try {
// Ejecutamos el comando y capturamos la salida como texto
const output = await $`supabase gen types typescript --linked`.text();
// Escribimos el archivo directamente con Bun (garantiza UTF-8)
await Bun.write("src/types/supabase.ts", output);
console.log("✅ Tipos actualizados correctamente con acentos.");
} catch (error) {
console.error("❌ Error generando tipos:", error);
}

View File

@@ -67,7 +67,7 @@ export function PasoConfiguracionPanel({
}, },
})) }))
} }
className="min-h-[100px]" className="min-h-25"
/> />
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1">
@@ -213,7 +213,7 @@ export function PasoConfiguracionPanel({
</div> </div>
</div> </div>
<div className="grid max-h-[300px] gap-2 overflow-y-auto"> <div className="grid max-h-75 gap-2 overflow-y-auto">
{MATERIAS_MOCK.map((m) => ( {MATERIAS_MOCK.map((m) => (
<div <div
key={m.id} key={m.id}

View File

@@ -31,7 +31,7 @@ export function StepWithTooltip({
{title} {title}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[200px] text-xs"> <TooltipContent className="max-w-50 text-xs">
<p>{desc}</p> <p>{desc}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

@@ -1,6 +1,9 @@
import { TemplateSelectorCard } from './TemplateSelectorCard' import type {
EstructuraPlanRow,
import type { CARRERAS } from '@/features/planes/nuevo/catalogs' FacultadRow,
NivelPlanEstudio,
TipoCiclo,
} from '@/data/types/domain'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -12,25 +15,30 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { useCatalogosPlanes } from '@/data/hooks/usePlans'
import { import { NIVELES, TIPOS_CICLO } from '@/features/planes/nuevo/catalogs'
FACULTADES,
NIVELES,
TIPOS_CICLO,
PLANTILLAS_ANEXO_1,
PLANTILLAS_ANEXO_2,
} from '@/features/planes/nuevo/catalogs'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function PasoBasicosForm({ export function PasoBasicosForm({
wizard, wizard,
onChange, onChange,
carrerasFiltradas,
}: { }: {
wizard: NewPlanWizardState wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>> onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
carrerasFiltradas: typeof CARRERAS
}) { }) {
const { data: catalogos } = useCatalogosPlanes()
// Preferir los catálogos remotos si están disponibles; si no, usar los locales
const facultadesList = catalogos?.facultades ?? []
const rawCarreras = catalogos?.carreras ?? []
const estructurasPlanList = catalogos?.estructurasPlan ?? []
const filteredCarreras = rawCarreras.filter((c: any) => {
const facId = wizard.datosBasicos.facultadId
if (!facId) return true
// soportar ambos shapes: `facultad_id` (BD) o `facultadId` (local)
return c.facultad_id ? c.facultad_id === facId : c.facultadId === facId
})
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
@@ -40,13 +48,18 @@ export function PasoBasicosForm({
</Label> </Label>
<Input <Input
id="nombrePlan" id="nombrePlan"
placeholder="Ej. Ingeniería en Sistemas 2026" placeholder="Ej. Ingeniería en Sistemas (2026)"
value={wizard.datosBasicos.nombrePlan} value={wizard.datosBasicos.nombrePlan}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value }, ...w,
})) datosBasicos: {
...w.datosBasicos,
nombrePlan: e.target.value,
},
}),
)
} }
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic" className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/> />
@@ -57,14 +70,16 @@ export function PasoBasicosForm({
<Select <Select
value={wizard.datosBasicos.facultadId} value={wizard.datosBasicos.facultadId}
onValueChange={(value) => onValueChange={(value) =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
datosBasicos: { ...w,
...w.datosBasicos, datosBasicos: {
facultadId: value, ...w.datosBasicos,
carreraId: '', facultadId: value,
}, carreraId: '',
})) },
}),
)
} }
> >
<SelectTrigger <SelectTrigger
@@ -79,7 +94,7 @@ export function PasoBasicosForm({
<SelectValue placeholder="Ej. Facultad de Ingeniería" /> <SelectValue placeholder="Ej. Facultad de Ingeniería" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FACULTADES.map((f) => ( {facultadesList.map((f: FacultadRow) => (
<SelectItem key={f.id} value={f.id}> <SelectItem key={f.id} value={f.id}>
{f.nombre} {f.nombre}
</SelectItem> </SelectItem>
@@ -93,10 +108,12 @@ export function PasoBasicosForm({
<Select <Select
value={wizard.datosBasicos.carreraId} value={wizard.datosBasicos.carreraId}
onValueChange={(value) => onValueChange={(value) =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
datosBasicos: { ...w.datosBasicos, carreraId: value }, ...w,
})) datosBasicos: { ...w.datosBasicos, carreraId: value },
}),
)
} }
disabled={!wizard.datosBasicos.facultadId} disabled={!wizard.datosBasicos.facultadId}
> >
@@ -112,7 +129,7 @@ export function PasoBasicosForm({
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" /> <SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{carrerasFiltradas.map((c) => ( {filteredCarreras.map((c: any) => (
<SelectItem key={c.id} value={c.id}> <SelectItem key={c.id} value={c.id}>
{c.nombre} {c.nombre}
</SelectItem> </SelectItem>
@@ -125,11 +142,13 @@ export function PasoBasicosForm({
<Label htmlFor="nivel">Nivel</Label> <Label htmlFor="nivel">Nivel</Label>
<Select <Select
value={wizard.datosBasicos.nivel} value={wizard.datosBasicos.nivel}
onValueChange={(value) => onValueChange={(value: NivelPlanEstudio) =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
datosBasicos: { ...w.datosBasicos, nivel: value }, ...w,
})) datosBasicos: { ...w.datosBasicos, nivel: value },
}),
)
} }
> >
<SelectTrigger <SelectTrigger
@@ -157,14 +176,16 @@ export function PasoBasicosForm({
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label> <Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
<Select <Select
value={wizard.datosBasicos.tipoCiclo} value={wizard.datosBasicos.tipoCiclo}
onValueChange={(value) => onValueChange={(value: TipoCiclo) =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
datosBasicos: { ...w,
...w.datosBasicos, datosBasicos: {
tipoCiclo: value as any, ...w.datosBasicos,
}, tipoCiclo: value as any,
})) },
}),
)
} }
> >
<SelectTrigger <SelectTrigger
@@ -180,8 +201,8 @@ export function PasoBasicosForm({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{TIPOS_CICLO.map((t) => ( {TIPOS_CICLO.map((t) => (
<SelectItem key={t.value} value={t.value}> <SelectItem key={t} value={t}>
{t.label} {t}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -196,22 +217,63 @@ export function PasoBasicosForm({
min={1} min={1}
value={wizard.datosBasicos.numCiclos ?? ''} value={wizard.datosBasicos.numCiclos ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
datosBasicos: { ...w,
...w.datosBasicos, datosBasicos: {
// Keep undefined when the input is empty so the field stays optional ...w.datosBasicos,
numCiclos: // Keep undefined when the input is empty so the field stays optional
e.target.value === '' ? undefined : Number(e.target.value), numCiclos:
}, e.target.value === ''
})) ? undefined
: Number(e.target.value),
},
}),
)
} }
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic" className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 8" placeholder="Ej. 8"
/> />
</div> </div>
<div className="grid gap-1">
<Label htmlFor="estructuraPlan">Estructura de plan de estudios</Label>
<Select
value={wizard.datosBasicos.estructuraPlanId ?? ''}
onValueChange={(value: string) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
estructuraPlanId: value,
},
}),
)
}
>
<SelectTrigger
id="tipoCiclo"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.estructuraPlanId
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Plan base SEP/ULSA (2026)" />
</SelectTrigger>
<SelectContent>
{estructurasPlanList.map((t: EstructuraPlanRow) => (
<SelectItem key={t.id} value={t.id}>
{t.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div> </div>
<Separator className="my-3" /> {/* <Separator className="my-3" />
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<TemplateSelectorCard <TemplateSelectorCard
cardTitle="Plantilla de plan de estudios" cardTitle="Plantilla de plan de estudios"
@@ -247,7 +309,7 @@ export function PasoBasicosForm({
})) }))
} }
/> />
</div> </div> */}
</div> </div>
) )
} }

View File

@@ -2,13 +2,13 @@ import { Upload, File, X, FileText } from 'lucide-react'
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { formatFileSize } from '@/features/planes/utils/format-file-size'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
interface UploadedFile { export interface UploadedFile {
id: string id: string // Necesario para React (key)
name: string file: File // La fuente de verdad (contiene name, size, type)
size: string preview?: string // Opcional: si fueran imágenes
type: string
} }
interface FileDropzoneProps { interface FileDropzoneProps {
@@ -37,9 +37,7 @@ export function FileDropzone({
typeof crypto !== 'undefined' && 'randomUUID' in crypto typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID() ? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, : `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name, file,
size: formatFileSize(file.size),
type: file.name.split('.').pop() || 'file',
})) }))
setFiles((prev) => { setFiles((prev) => {
const room = Math.max(0, maxFiles - prev.length) const room = Math.max(0, maxFiles - prev.length)
@@ -97,12 +95,6 @@ export function FileDropzone({
if (onFilesChangeRef.current) onFilesChangeRef.current(files) if (onFilesChangeRef.current) onFilesChangeRef.current(files)
}, [files]) }, [files])
const 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'
}
const getFileIcon = (type: string) => { const getFileIcon = (type: string) => {
switch (type.toLowerCase()) { switch (type.toLowerCase()) {
case 'pdf': case 'pdf':
@@ -170,23 +162,25 @@ export function FileDropzone({
{/* Uploaded files list */} {/* Uploaded files list */}
{files.length > 0 && ( {files.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{files.map((file) => ( {files.map((item) => (
<div <div
key={file.id} key={item.id}
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3" className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
> >
{getFileIcon(file.type)} {getFileIcon(item.file.type)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium"> <p className="text-foreground truncate text-sm font-medium">
{file.name} {item.file.name}
</p>
<p className="text-muted-foreground text-xs">
{formatFileSize(item.file.size)}
</p> </p>
<p className="text-muted-foreground text-xs">{file.size}</p>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-destructive h-8 w-8" className="text-muted-foreground hover:text-destructive h-8 w-8"
onClick={() => removeFile(file.id)} onClick={() => removeFile(item.id)}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>

View File

@@ -1,6 +1,7 @@
import { FileDropzone } from './FileDropZone' import { FileDropzone } from './FileDropZone'
import ReferenciasParaIA from './ReferenciasParaIA' import ReferenciasParaIA from './ReferenciasParaIA'
import type { UploadedFile } from './FileDropZone'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -30,7 +31,7 @@ export function PasoDetallesPanel({
onGenerarIA: () => void onGenerarIA: () => void
isLoading: boolean isLoading: boolean
}) { }) {
if (wizard.modoCreacion === 'MANUAL') { if (wizard.tipoOrigen === 'MANUAL') {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -43,7 +44,7 @@ export function PasoDetallesPanel({
) )
} }
if (wizard.modoCreacion === 'IA') { if (wizard.tipoOrigen === 'IA') {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -116,14 +117,16 @@ export function PasoDetallesPanel({
} }
}) })
} }
onFilesChange={(files) => onFilesChange={(files: Array<UploadedFile>) =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
iaConfig: { ...w,
...(w.iaConfig || ({} as any)), iaConfig: {
archivosAdjuntos: files, ...(w.iaConfig || ({} as any)),
}, archivosAdjuntos: files,
})) },
}),
)
} }
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -162,10 +165,7 @@ export function PasoDetallesPanel({
) )
} }
if ( if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'INTERNO'
) {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
@@ -269,10 +269,7 @@ export function PasoDetallesPanel({
) )
} }
if ( if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'TRADICIONAL'
) {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">

View File

@@ -5,6 +5,8 @@ import BarraBusqueda from '../../BarraBusqueda'
import { FileDropzone } from './FileDropZone' import { FileDropzone } from './FileDropZone'
import type { UploadedFile } from './FileDropZone'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
@@ -27,9 +29,7 @@ const ReferenciasParaIA = ({
selectedRepositorioIds?: Array<string> selectedRepositorioIds?: Array<string>
onToggleArchivo?: (id: string, checked: boolean) => void onToggleArchivo?: (id: string, checked: boolean) => void
onToggleRepositorio?: (id: string, checked: boolean) => void onToggleRepositorio?: (id: string, checked: boolean) => void
onFilesChange?: ( onFilesChange?: (files: Array<UploadedFile>) => void
files: Array<{ id: string; name: string; size: string; type: string }>,
) => void
}) => { }) => {
const [busquedaArchivos, setBusquedaArchivos] = useState('') const [busquedaArchivos, setBusquedaArchivos] = useState('')
const [busquedaRepositorios, setBusquedaRepositorios] = useState('') const [busquedaRepositorios, setBusquedaRepositorios] = useState('')

View File

@@ -1,10 +1,7 @@
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import type { import type { TipoOrigen } from '@/data/types/domain'
NewPlanWizardState, import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
ModoCreacion,
SubModoClonado,
} from '@/features/planes/nuevo/types'
import { import {
Card, Card,
@@ -21,8 +18,7 @@ export function PasoModoCardGroup({
wizard: NewPlanWizardState wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>> onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
}) { }) {
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m const isSelected = (m: TipoOrigen) => wizard.tipoOrigen === m
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => { const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
const key = e.key const key = e.key
if ( if (
@@ -41,19 +37,21 @@ export function PasoModoCardGroup({
<Card <Card
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''} className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
onClick={() => onClick={() =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
modoCreacion: 'MANUAL', ...w,
subModoClonado: undefined, tipoOrigen: 'MANUAL',
})) }),
)
} }
onKeyDown={(e: React.KeyboardEvent) => onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () => handleKeyActivate(e, () =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
modoCreacion: 'MANUAL', ...w,
subModoClonado: undefined, tipoOrigen: 'MANUAL',
})), }),
),
) )
} }
role="button" role="button"
@@ -70,19 +68,21 @@ export function PasoModoCardGroup({
<Card <Card
className={isSelected('IA') ? 'ring-ring ring-2' : ''} className={isSelected('IA') ? 'ring-ring ring-2' : ''}
onClick={() => onClick={() =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
modoCreacion: 'IA', ...w,
subModoClonado: undefined, tipoOrigen: 'IA',
})) }),
)
} }
onKeyDown={(e: React.KeyboardEvent) => onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () => handleKeyActivate(e, () =>
onChange((w) => ({ onChange(
...w, (w): NewPlanWizardState => ({
modoCreacion: 'IA', ...w,
subModoClonado: undefined, tipoOrigen: 'IA',
})), }),
),
) )
} }
role="button" role="button"
@@ -99,11 +99,13 @@ export function PasoModoCardGroup({
</Card> </Card>
<Card <Card
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''} className={isSelected('OTRO') ? 'ring-ring ring-2' : ''}
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))} onClick={() =>
onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' }))
}
onKeyDown={(e: React.KeyboardEvent) => onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () => handleKeyActivate(e, () =>
onChange((w) => ({ ...w, modoCreacion: 'CLONADO' })), onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' })),
) )
} }
role="button" role="button"
@@ -115,22 +117,34 @@ export function PasoModoCardGroup({
</CardTitle> </CardTitle>
<CardDescription>Desde un plan existente o archivos.</CardDescription> <CardDescription>Desde un plan existente o archivos.</CardDescription>
</CardHeader> </CardHeader>
{wizard.modoCreacion === 'CLONADO' && ( {(wizard.tipoOrigen === 'OTRO' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })) onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'CLONADO_INTERNO',
}),
)
}} }}
onKeyDown={(e: React.KeyboardEvent) => onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () => handleKeyActivate(e, () =>
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })), onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'CLONADO_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 ${ 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') isSelected('CLONADO_INTERNO')
? 'border-primary bg-primary/5 ring-primary text-primary ring-1' ? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
: 'border-border text-muted-foreground' : 'border-border text-muted-foreground'
} `} } `}
@@ -144,15 +158,25 @@ export function PasoModoCardGroup({
tabIndex={0} tabIndex={0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })) onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'CLONADO_TRADICIONAL',
}),
)
}} }}
onKeyDown={(e: React.KeyboardEvent) => onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () => handleKeyActivate(e, () =>
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })), onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'CLONADO_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 ${ 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') isSelected('CLONADO_TRADICIONAL')
? 'border-primary bg-primary/5 ring-primary text-primary ring-1' ? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
: 'border-border text-muted-foreground' : 'border-border text-muted-foreground'
} `} } `}

View File

@@ -8,12 +8,11 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { import {
PLANTILLAS_ANEXO_1,
PLANTILLAS_ANEXO_2,
PLANES_EXISTENTES, PLANES_EXISTENTES,
ARCHIVOS, ARCHIVOS,
REPOSITORIOS, REPOSITORIOS,
} from '@/features/planes/nuevo/catalogs' } from '@/features/planes/nuevo/catalogs'
import { formatFileSize } from '@/features/planes/utils/format-file-size'
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) { export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
return ( return (
@@ -32,12 +31,6 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
const repositoriosRef = const repositoriosRef =
wizard.iaConfig?.repositoriosReferencia ?? [] wizard.iaConfig?.repositoriosReferencia ?? []
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? [] 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 = ( const contenido = (
<> <>
<div> <div>
@@ -68,89 +61,56 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
{wizard.datosBasicos.tipoCiclo}) {wizard.datosBasicos.tipoCiclo})
</span> </span>
</div> </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"> <div className="mt-2">
<span className="text-muted-foreground">Modo: </span> <span className="text-muted-foreground">Modo: </span>
<span className="font-medium"> <span className="font-medium">
{wizard.modoCreacion === 'MANUAL' && 'Manual'} {wizard.tipoOrigen === 'MANUAL' && 'Manual'}
{wizard.modoCreacion === 'IA' && 'Generado con IA'} {wizard.tipoOrigen === 'IA' && 'Generado con IA'}
{wizard.modoCreacion === 'CLONADO' && {wizard.tipoOrigen === 'CLONADO_INTERNO' &&
wizard.subModoClonado === 'INTERNO' &&
'Clonado desde plan del sistema'} 'Clonado desde plan del sistema'}
{wizard.modoCreacion === 'CLONADO' && {wizard.tipoOrigen === 'CLONADO_TRADICIONAL' &&
wizard.subModoClonado === 'TRADICIONAL' &&
'Importado desde documentos tradicionales'} 'Importado desde documentos tradicionales'}
</span> </span>
</div> </div>
{wizard.modoCreacion === 'CLONADO' && {wizard.tipoOrigen === 'CLONADO_INTERNO' && (
wizard.subModoClonado === 'INTERNO' && ( <div className="mt-2">
<div className="mt-2"> <span className="text-muted-foreground">Plan origen: </span>
<span className="text-muted-foreground"> <span className="font-medium">
Plan origen:{' '} {(() => {
</span> const p = PLANES_EXISTENTES.find(
<span className="font-medium"> (x) => x.id === wizard.clonInterno?.planOrigenId,
{(() => { )
const p = PLANES_EXISTENTES.find( return (
(x) => x.id === wizard.clonInterno?.planOrigenId, p?.nombre || wizard.clonInterno?.planOrigenId || '—'
) )
return ( })()}
p?.nombre || wizard.clonInterno?.planOrigenId || '—' </span>
) </div>
})()} )}
</span> {wizard.tipoOrigen === 'CLONADO_TRADICIONAL' && (
</div> <div className="mt-2">
)} <div className="font-medium">Documentos adjuntos</div>
{wizard.modoCreacion === 'CLONADO' && <ul className="text-muted-foreground list-disc pl-5 text-xs">
wizard.subModoClonado === 'TRADICIONAL' && ( <li>
<div className="mt-2"> <span className="text-foreground">Word del plan:</span>{' '}
<div className="font-medium">Documentos adjuntos</div> {wizard.clonTradicional?.archivoWordPlanId?.name || '—'}
<ul className="text-muted-foreground list-disc pl-5 text-xs"> </li>
<li> <li>
<span className="text-foreground"> <span className="text-foreground">
Word del plan: Mapa curricular:
</span>{' '} </span>{' '}
{wizard.clonTradicional?.archivoWordPlanId?.name || {wizard.clonTradicional?.archivoMapaExcelId?.name ||
'—'} '—'}
</li> </li>
<li> <li>
<span className="text-foreground"> <span className="text-foreground">Asignaturas:</span>{' '}
Mapa curricular: {wizard.clonTradicional?.archivoAsignaturasExcelId
</span>{' '} ?.name || ''}
{wizard.clonTradicional?.archivoMapaExcelId?.name || </li>
'—'} </ul>
</li> </div>
<li> )}
<span className="text-foreground">Asignaturas:</span>{' '} {wizard.tipoOrigen === 'IA' && (
{wizard.clonTradicional?.archivoAsignaturasExcelId
?.name || '—'}
</li>
</ul>
</div>
)}
{wizard.modoCreacion === 'IA' && (
<div className="bg-muted/50 mt-2 rounded-md p-3"> <div className="bg-muted/50 mt-2 rounded-md p-3">
<div> <div>
<span className="text-muted-foreground">Enfoque: </span> <span className="text-muted-foreground">Enfoque: </span>
@@ -208,8 +168,10 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
<ul className="text-muted-foreground list-disc pl-5 text-xs"> <ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => ( {adjuntos.map((f) => (
<li key={f.id}> <li key={f.id}>
<span className="text-foreground">{f.name}</span>{' '} <span className="text-foreground">
<span>· {f.size}</span> {f.file.name}
</span>{' '}
<span>· {formatFileSize(f.file.size)}</span>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -32,7 +32,7 @@ export function StepWithTooltip({
{title} {title}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[200px] text-xs"> <TooltipContent className="max-w-50 text-xs">
<p>{desc}</p> <p>{desc}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

@@ -1,52 +1,54 @@
import * as React from "react" import { Slot } from '@radix-ui/react-slot'
import { Slot } from "@radix-ui/react-slot" import { cva } from 'class-variance-authority'
import { cva, type VariantProps } from "class-variance-authority" import * as React from 'react'
import { cn } from "@/lib/utils" import type { VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: "text-primary underline-offset-4 hover:underline", link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: "size-9", icon: 'size-9',
"icon-sm": "size-8", 'icon-sm': 'size-8',
"icon-lg": "size-10", 'icon-lg': 'size-10',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} },
) )
function Button({ function Button({
className, className,
variant = "default", variant = 'default',
size = "default", size = 'default',
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button'
return ( return (
<Comp <Comp

View File

@@ -1,6 +1,8 @@
import { supabaseBrowser } from '../supabase/client' import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge' import { invokeEdge } from '../supabase/invokeEdge'
import { buildRange, throwIfError, requireData } from './_helpers'
import { buildRange, requireData, throwIfError } from './_helpers'
import type { import type {
Asignatura, Asignatura,
CambioPlan, CambioPlan,
@@ -12,12 +14,14 @@ import type {
TipoCiclo, TipoCiclo,
UUID, UUID,
} from '../types/domain' } from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
const EDGE = { const EDGE = {
plans_create_manual: 'plans_create_manual', plans_create_manual: 'plans_create_manual',
ai_generate_plan: 'ai_generate_plan', ai_generate_plan: 'ai_generate_plan',
plans_persist_from_ai: 'plans_persist_from_ai', plans_persist_from_ai: 'plans_persist_from_ai',
plans_clone_from_existing: 'plans_clone_from_existing', plans_clone_from_existing: 'plans_clone_from_existing',
plans_import_from_files: 'plans_import_from_files', plans_import_from_files: 'plans_import_from_files',
plans_update_fields: 'plans_update_fields', plans_update_fields: 'plans_update_fields',
@@ -39,40 +43,82 @@ export type PlanListFilters = {
offset?: number offset?: number
} }
// Helper para limpiar texto (lo movemos fuera para reutilizar o lo dejas en un utils)
const cleanText = (text: string) => {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
}
export async function plans_list( export async function plans_list(
filters: PlanListFilters = {}, filters: PlanListFilters = {},
): Promise<Paged<PlanEstudio>> { ): Promise<Paged<PlanEstudio>> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
// 1. Construimos la query base
// NOTA IMPORTANTE: Para filtrar planes basados en facultad (que está en carreras),
// necesitamos hacer un INNER JOIN. En Supabase se usa "!inner".
// Si filters.facultadId existe, forzamos el inner join, si no, lo dejamos normal.
const carreraModifier =
filters.facultadId && filters.facultadId !== 'todas' ? '!inner' : ''
let q = supabase let q = supabase
.from('planes_estudio') .from('planes_estudio')
.select( .select(
` `
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, *,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)), carreras${carreraModifier} (
estructuras_plan(id,nombre,tipo,version,definicion), *,
estados_plan(id,clave,etiqueta,orden,es_final) facultades (*)
`, ),
estructuras_plan (*),
estados_plan (*)
`,
{ count: 'exact' }, { count: 'exact' },
) )
.order('actualizado_en', { ascending: false }) .order('actualizado_en', { ascending: false })
if (filters.search?.trim()) // 2. Aplicamos filtros dinámicos
q = q.ilike('nombre', `%${filters.search.trim()}%`)
if (filters.carreraId) q = q.eq('carrera_id', filters.carreraId)
if (filters.estadoId) q = q.eq('estado_actual_id', filters.estadoId)
if (typeof filters.activo === 'boolean') q = q.eq('activo', filters.activo)
// filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos) // SOLUCIÓN SEARCH: Limpiamos el input y buscamos en la columna generada
if (filters.facultadId) q = q.eq('carreras.facultad_id', filters.facultadId) if (filters.search?.trim()) {
const cleanTerm = cleanText(filters.search.trim())
// Usamos la columna nueva creada en el Paso 1
q = q.ilike('nombre_search', `%${cleanTerm}%`)
}
if (filters.carreraId && filters.carreraId !== 'todas') {
q = q.eq('carrera_id', filters.carreraId)
}
if (filters.estadoId && filters.estadoId !== 'todos') {
q = q.eq('estado_actual_id', filters.estadoId)
}
if (typeof filters.activo === 'boolean') {
q = q.eq('activo', filters.activo)
}
// Filtro por facultad (gracias al !inner arriba, esto filtrará los planes)
if (filters.facultadId && filters.facultadId !== 'todas') {
q = q.eq('carreras.facultad_id', filters.facultadId)
}
// 3. Paginación
const { from, to } = buildRange(filters.limit, filters.offset) const { from, to } = buildRange(filters.limit, filters.offset)
if (typeof from === 'number' && typeof to === 'number') q = q.range(from, to) if (from !== undefined && to !== undefined) q = q.range(from, to)
const { data, error, count } = await q const { data, error, count } = await q
throwIfError(error) throwIfError(error)
return { data: data ?? [], count: count ?? null } return {
// 1. Si data es null, usa [].
// 2. Luego dile a TS que el resultado es tu Array tipado.
data: (data ?? []) as unknown as Array<PlanEstudio>,
count: count ?? 0,
}
} }
export async function plans_get(planId: UUID): Promise<PlanEstudio> { export async function plans_get(planId: UUID): Promise<PlanEstudio> {
@@ -82,10 +128,10 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
.from('planes_estudio') .from('planes_estudio')
.select( .select(
` `
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, *,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)), carreras (*, facultades(*)),
estructuras_plan(id,nombre,tipo,template_id,definicion), estructuras_plan (*),
estados_plan(id,clave,etiqueta,orden,es_final) estados_plan (*)
`, `,
) )
.eq('id', planId) .eq('id', planId)
@@ -95,7 +141,9 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
return requireData(data, 'Plan no encontrado.') return requireData(data, 'Plan no encontrado.')
} }
export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> { export async function plan_lineas_list(
planId: UUID,
): Promise<Array<LineaPlan>> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('lineas_plan') .from('lineas_plan')
@@ -109,7 +157,7 @@ export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> {
export async function plan_asignaturas_list( export async function plan_asignaturas_list(
planId: UUID, planId: UUID,
): Promise<Asignatura[]> { ): Promise<Array<Asignatura>> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from('asignaturas')
@@ -125,7 +173,7 @@ export async function plan_asignaturas_list(
return data ?? [] return data ?? []
} }
export async function plans_history(planId: UUID): Promise<CambioPlan[]> { export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('cambios_plan') .from('cambios_plan')
@@ -170,8 +218,9 @@ export type AIGeneratePlanInput = {
descripcionEnfoque: string descripcionEnfoque: string
poblacionObjetivo?: string poblacionObjetivo?: string
notasAdicionales?: string notasAdicionales?: string
archivosReferencia?: UUID[] archivosReferencia?: Array<UUID>
repositoriosIds?: UUID[] repositoriosIds?: Array<UUID>
archivosAdjuntos: Array<UploadedFile>
usarMCP?: boolean usarMCP?: boolean
} }
} }
@@ -246,12 +295,12 @@ export type PlanMapOperation =
op: 'REORDER_CELDA' op: 'REORDER_CELDA'
linea_plan_id: UUID linea_plan_id: UUID
numero_ciclo: number numero_ciclo: number
asignaturaIdsOrdenados: UUID[] asignaturaIdsOrdenados: Array<UUID>
} }
export async function plans_update_map( export async function plans_update_map(
planId: UUID, planId: UUID,
ops: PlanMapOperation[], ops: Array<PlanMapOperation>,
): Promise<{ ok: true }> { ): Promise<{ ok: true }> {
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops }) return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops })
} }
@@ -281,5 +330,28 @@ export async function plans_generate_document(
export async function plans_get_document( export async function plans_get_document(
planId: UUID, planId: UUID,
): Promise<DocumentoResult | null> { ): Promise<DocumentoResult | null> {
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, { planId }) return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, {
planId,
})
}
export async function getCatalogos() {
const supabase = supabaseBrowser()
const [facultadesRes, carrerasRes, estadosRes, estructurasPlanRes] =
await Promise.all([
supabase.from('facultades').select('*').order('nombre'),
supabase.from('carreras').select('*').order('nombre'),
supabase.from('estados_plan').select('*').order('orden'),
supabase.from('estructuras_plan').select('*').order('creado_en', {
ascending: true,
}),
])
return {
facultades: facultadesRes.data ?? [],
carreras: carrerasRes.data ?? [],
estados: estadosRes.data ?? [],
estructurasPlan: estructurasPlanRes.data ?? [],
}
} }

View File

@@ -1,4 +1,5 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import {
ai_plan_chat, ai_plan_chat,
ai_plan_improve, ai_plan_improve,

View File

@@ -14,6 +14,7 @@ import type {
} from '../api/plans.api' } from '../api/plans.api'
import { import {
ai_generate_plan, ai_generate_plan,
getCatalogos,
plan_asignaturas_list, plan_asignaturas_list,
plan_lineas_list, plan_lineas_list,
plans_clone_from_existing, plans_clone_from_existing,
@@ -33,8 +34,13 @@ import {
export function usePlanes(filters: PlanListFilters) { export function usePlanes(filters: PlanListFilters) {
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable. // 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
return useQuery({ return useQuery({
// Usamos la factory de keys para consistencia
queryKey: qk.planesList(filters), queryKey: qk.planesList(filters),
// La función fetch
queryFn: () => plans_list(filters), queryFn: () => plans_list(filters),
// UX: Mantiene los datos viejos mientras carga la paginación nueva
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}) })
} }

View File

@@ -1,14 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (failureCount) => failureCount < 2,
},
mutations: {
retry: 0,
},
},
});

View File

@@ -1,7 +1,20 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export function getContext() { export function getContext() {
const queryClient = new QueryClient() const queryClient = new QueryClient(
{
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (failureCount) => failureCount < 2,
},
mutations: {
retry: 0,
},
},
}
)
return { return {
queryClient, queryClient,
} }

View File

@@ -1,7 +1,10 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { createClient } from "@supabase/supabase-js";
import type { Database } from "../types/database";
import { getEnv } from "./env"; import { getEnv } from "./env";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "src/types/supabase";
let _client: SupabaseClient<Database> | null = null; let _client: SupabaseClient<Database> | null = null;
export function supabaseBrowser(): SupabaseClient<Database> { export function supabaseBrowser(): SupabaseClient<Database> {
@@ -10,13 +13,13 @@ export function supabaseBrowser(): SupabaseClient<Database> {
const url = getEnv( const url = getEnv(
"VITE_SUPABASE_URL", "VITE_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_URL",
"SUPABASE_URL" "SUPABASE_URL",
); );
const anonKey = getEnv( const anonKey = getEnv(
"VITE_SUPABASE_ANON_KEY", "VITE_SUPABASE_ANON_KEY",
"NEXT_PUBLIC_SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY",
"SUPABASE_ANON_KEY" "SUPABASE_ANON_KEY",
); );
_client = createClient<Database>(url, anonKey, { _client = createClient<Database>(url, anonKey, {

View File

@@ -1,9 +0,0 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json }
| Json[];
export type Database = any; // ✅ Reemplaza por tipos generados (supabase gen types typescript)

View File

@@ -1,29 +1,22 @@
import type { Json } from "./database"; import type { Enums, Tables } from "../../types/supabase";
export type UUID = string; export type UUID = string;
export type TipoEstructuraPlan = "CURRICULAR" | "NO_CURRICULAR"; export type TipoEstructuraPlan = Enums<"tipo_estructura_plan">;
export type NivelPlanEstudio = export type NivelPlanEstudio = Enums<"nivel_plan_estudio">;
| "LICENCIATURA" export type TipoCiclo = Enums<"tipo_ciclo">;
| "MAESTRIA"
| "DOCTORADO"
| "ESPECIALIDAD"
| "DIPLOMADO"
| "OTRO";
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE" | "OTRO"; export type TipoOrigen = Enums<"tipo_origen">;
export type TipoOrigen = "MANUAL" | "IA" | "CLONADO_INTERNO" | "TRADICIONAL" | "OTRO"; export type TipoAsignatura = Enums<"tipo_asignatura">;
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRA"; export type TipoBibliografia = Enums<"tipo_bibliografia">;
export type TipoFuenteBibliografia = Enums<"tipo_fuente_bibliografia">;
export type TipoBibliografia = "BASICA" | "COMPLEMENTARIA"; export type EstadoTareaRevision = Enums<"estado_tarea_revision">;
export type TipoFuenteBibliografia = "MANUAL" | "BIBLIOTECA"; export type TipoNotificacion = Enums<"tipo_notificacion">;
export type EstadoTareaRevision = "PENDIENTE" | "COMPLETADA" | "OMITIDA"; export type TipoInteraccionIA = Enums<"tipo_interaccion_ia">;
export type TipoNotificacion = "PLAN_ASIGNADO" | "ESTADO_CAMBIADO" | "TAREA_ASIGNADA" | "COMENTARIO" | "OTRA";
export type TipoInteraccionIA = "GENERAR" | "MEJORAR_SECCION" | "CHAT" | "OTRA";
export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta"; export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta";
export type DisenoCurricular = "Rígido" | "Flexible"; export type DisenoCurricular = "Rígido" | "Flexible";
@@ -58,218 +51,49 @@ export type PlanDatosSep = {
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null; propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
}; };
export type Paged<T> = { data: T[]; count: number | null }; export type PlanEstudioWithRel =
& Tables<"planes_estudio">
& {
carreras:
| Tables<"carreras"> & {
facultades: Tables<"facultades"> | null;
}
| null;
estados_plan: Tables<"estados_plan"> | null;
};
export type Facultad = { export type Paged<T> = { data: Array<T>; count: number | null };
id: UUID;
nombre: string; export type FacultadRow = Tables<"facultades">;
nombre_corto: string | null; export type CarreraRow = Tables<"carreras">;
color: string | null;
icono: string | null; export type EstructuraPlanRow = Tables<"estructuras_plan">;
creado_en: string;
actualizado_en: string; export type EstructuraAsignatura = Tables<"estructuras_asignatura">;
export type EstadoPlanRow = Tables<"estados_plan">;
export type PlanEstudioRow = Tables<"planes_estudio">;
export type PlanEstudio = PlanEstudioRow & {
carreras: (CarreraRow & { facultades: FacultadRow | null }) | null;
estructuras_plan: EstructuraPlanRow | null;
estados_plan: EstadoPlanRow | null;
}; };
export type Carrera = { export type LineaPlan = Tables<"lineas_plan">;
id: UUID;
facultad_id: UUID;
nombre: string;
nombre_corto: string | null;
clave_sep: string | null;
activa: boolean;
creado_en: string;
actualizado_en: string;
facultades?: Facultad | null; export type Asignatura = Tables<"asignaturas">;
};
export type EstructuraPlan = { export type BibliografiaAsignatura = Tables<"bibliografia_asignatura">;
id: UUID;
nombre: string;
tipo: TipoEstructuraPlan;
version: string | null;
definicion: Json;
};
export type EstructuraAsignatura = { export type CambioPlan = Tables<"cambios_plan">;
id: UUID;
nombre: string;
version: string | null;
definicion: Json;
};
export type EstadoPlan = { export type CambioAsignatura = Tables<"cambios_asignatura">;
id: UUID;
clave: string;
etiqueta: string;
orden: number;
es_final: boolean;
};
export type PlanEstudio = { export type InteraccionIA = Tables<"interacciones_ia">;
id: UUID;
carrera_id: UUID;
estructura_id: UUID;
nombre: string; export type TareaRevision = Tables<"tareas_revision">;
nivel: NivelPlanEstudio;
tipo_ciclo: TipoCiclo;
numero_ciclos: number;
datos: Json; export type Notificacion = Tables<"notificaciones">;
estado_actual_id: UUID | null; export type Archivo = Tables<"archivos">;
activo: boolean;
tipo_origen: TipoOrigen | null;
meta_origen: Json;
creado_por: UUID | null;
actualizado_por: UUID | null;
creado_en: string;
actualizado_en: string;
carreras?: Carrera | null;
estructuras_plan?: EstructuraPlan | null;
estados_plan?: EstadoPlan | null;
};
export type LineaPlan = {
id: UUID;
plan_estudio_id: UUID;
nombre: string;
orden: number;
area: string | null;
creado_en: string;
actualizado_en: string;
};
export type Asignatura = {
id: UUID;
plan_estudio_id: UUID;
estructura_id: UUID | null;
facultad_propietaria_id: UUID | null;
codigo: string | null;
nombre: string;
tipo: TipoAsignatura;
creditos: number;
horas_semana: number | null;
numero_ciclo: number | null;
linea_plan_id: UUID | null;
orden_celda: number | null;
datos: Json;
contenido_tematico: Json;
tipo_origen: TipoOrigen | null;
meta_origen: Json;
creado_por: UUID | null;
actualizado_por: UUID | null;
creado_en: string;
actualizado_en: string;
planes_estudio?: PlanEstudio | null;
estructuras_asignatura?: EstructuraAsignatura | null;
};
export type BibliografiaAsignatura = {
id: UUID;
asignatura_id: UUID;
tipo: TipoBibliografia;
cita: string;
tipo_fuente: TipoFuenteBibliografia;
biblioteca_item_id: string | null;
creado_por: UUID | null;
creado_en: string;
actualizado_en: string;
};
export type CambioPlan = {
id: UUID;
plan_estudio_id: UUID;
cambiado_por: UUID | null;
cambiado_en: string;
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
campo: string | null;
valor_anterior: Json | null;
valor_nuevo: Json | null;
interaccion_ia_id: UUID | null;
};
export type CambioAsignatura = {
id: UUID;
asignatura_id: UUID;
cambiado_por: UUID | null;
cambiado_en: string;
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
campo: string | null;
valor_anterior: Json | null;
valor_nuevo: Json | null;
fuente: "HUMANO" | "IA" | null;
interaccion_ia_id: UUID | null;
};
export type InteraccionIA = {
id: UUID;
usuario_id: UUID | null;
plan_estudio_id: UUID | null;
asignatura_id: UUID | null;
tipo: TipoInteraccionIA;
modelo: string | null;
temperatura: number | null;
prompt: Json;
respuesta: Json;
aceptada: boolean;
conversacion_id: string | null;
ids_archivos: Json;
ids_vector_store: Json;
creado_en: string;
};
export type TareaRevision = {
id: UUID;
plan_estudio_id: UUID;
asignado_a: UUID;
rol_id: UUID | null;
estado_id: UUID | null;
estatus: EstadoTareaRevision;
fecha_limite: string | null;
creado_en: string;
completado_en: string | null;
};
export type Notificacion = {
id: UUID;
usuario_id: UUID;
tipo: TipoNotificacion;
payload: Json;
leida: boolean;
creado_en: string;
leida_en: string | null;
};
export type Archivo = {
id: UUID;
ruta_storage: string;
nombre: string;
mime_type: string | null;
bytes: number | null;
subido_por: UUID | null;
subido_en: string;
temporal: boolean;
openai_file_id: string | null;
notas: string | null;
};

View File

@@ -3,6 +3,8 @@ import * as Icons from 'lucide-react'
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard' import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
import type { NewPlanWizardState } from './types'
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm' import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel' import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel'
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup' import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
@@ -49,7 +51,6 @@ export default function NuevoPlanModalContainer() {
const { const {
wizard, wizard,
setWizard, setWizard,
carrerasFiltradas,
canContinueDesdeModo, canContinueDesdeModo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeDetalles, canContinueDesdeDetalles,
@@ -61,12 +62,20 @@ export default function NuevoPlanModalContainer() {
} }
const crearPlan = async () => { const crearPlan = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null })) setWizard((w: NewPlanWizardState) => ({
...w,
isLoading: true,
errorMessage: null,
}))
await new Promise((r) => setTimeout(r, 900)) await new Promise((r) => setTimeout(r, 900))
const nuevoId = (() => { const nuevoId = (() => {
if (wizard.modoCreacion === 'MANUAL') return 'plan_new_manual_001' if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
if (wizard.modoCreacion === 'IA') return 'plan_new_ai_001' if (wizard.tipoOrigen === 'IA') return 'plan_new_ai_001'
if (wizard.subModoClonado === 'INTERNO') return 'plan_new_clone_001' if (
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
)
return 'plan_new_clone_001'
return 'plan_new_import_001' return 'plan_new_import_001'
})() })()
navigate({ to: `/planes/${nuevoId}` }) navigate({ to: `/planes/${nuevoId}` })
@@ -115,7 +124,10 @@ export default function NuevoPlanModalContainer() {
{({ methods }) => { {({ methods }) => {
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1 const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
const totalSteps = Wizard.steps.length const totalSteps = Wizard.steps.length
const nextStep = Wizard.steps[currentIndex] const nextStep = Wizard.steps[currentIndex] ?? {
title: '',
description: '',
}
return ( return (
<> <>
@@ -124,7 +136,7 @@ export default function NuevoPlanModalContainer() {
totalSteps={totalSteps} totalSteps={totalSteps}
currentTitle={methods.current.title} currentTitle={methods.current.title}
currentDescription={methods.current.description} currentDescription={methods.current.description}
nextTitle={nextStep?.title} nextTitle={nextStep.title}
onClose={handleClose} onClose={handleClose}
Wizard={Wizard} Wizard={Wizard}
/> />
@@ -144,7 +156,6 @@ export default function NuevoPlanModalContainer() {
<PasoBasicosForm <PasoBasicosForm
wizard={wizard} wizard={wizard}
onChange={setWizard} onChange={setWizard}
carrerasFiltradas={carrerasFiltradas}
/> />
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}

View File

@@ -1,4 +1,4 @@
import type { TipoCiclo } from "./types"; import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain";
export const FACULTADES = [ export const FACULTADES = [
{ id: "ing", nombre: "Facultad de Ingeniería" }, { id: "ing", nombre: "Facultad de Ingeniería" },
@@ -16,16 +16,20 @@ export const CARRERAS = [
{ id: "act", nombre: "Actuaría", facultadId: "neg" }, { id: "act", nombre: "Actuaría", facultadId: "neg" },
]; ];
export const NIVELES = [ export const NIVELES: Array<NivelPlanEstudio> = [
"Licenciatura", "Licenciatura",
"Especialidad",
"Maestría", "Maestría",
"Doctorado", "Doctorado",
"Especialidad",
"Diplomado",
"Otro",
]; ];
export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [
{ value: "SEMESTRE", label: "Semestre" }, export const TIPOS_CICLO: Array<TipoCiclo> = [
{ value: "CUATRIMESTRE", label: "Cuatrimestre" }, "Semestre",
{ value: "TRIMESTRE", label: "Trimestre" }, "Cuatrimestre",
"Trimestre",
"Otro",
]; ];
export const PLANES_EXISTENTES = [ export const PLANES_EXISTENTES = [

View File

@@ -1,37 +1,33 @@
import { useMemo, useState } from "react"; import { useState } from "react";
import { CARRERAS } from "../catalogs"; import type { NewPlanWizardState, PlanPreview } from "../types";
import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain";
import type { NewPlanWizardState, PlanPreview, TipoCiclo } from "../types";
export function useNuevoPlanWizard() { export function useNuevoPlanWizard() {
const [wizard, setWizard] = useState<NewPlanWizardState>({ const [wizard, setWizard] = useState<NewPlanWizardState>({
step: 1, step: 1,
modoCreacion: null, tipoOrigen: null,
// datosBasicos: {
// nombrePlan: "",
// carreraId: "",
// facultadId: "",
// nivel: "",
// tipoCiclo: "",
// numCiclos: undefined,
// plantillaPlanId: "",
// plantillaPlanVersion: "",
// plantillaMapaId: "",
// plantillaMapaVersion: "",
// },
datosBasicos: { datosBasicos: {
nombrePlan: "Medicina", nombrePlan: "",
carreraId: "medico", carreraId: "",
facultadId: "med", facultadId: "",
nivel: "Licenciatura", nivel: "",
tipoCiclo: "SEMESTRE", tipoCiclo: "",
numCiclos: 8, numCiclos: undefined,
plantillaPlanId: "sep-2025", estructuraPlanId: null,
plantillaPlanVersion: "v2025.2 (Vigente)",
plantillaMapaId: "sep-2017-xlsx",
plantillaMapaVersion: "v2017.0",
}, },
// datosBasicos: {
// nombrePlan: "Medicina",
// carreraId: "medico",
// facultadId: "med",
// nivel: "Licenciatura",
// tipoCiclo: "SEMESTRE",
// numCiclos: 8,
// plantillaPlanId: "sep-2025",
// plantillaPlanVersion: "v2025.2 (Vigente)",
// plantillaMapaId: "sep-2017-xlsx",
// plantillaMapaVersion: "v2017.0",
// },
clonInterno: { planOrigenId: null }, clonInterno: { planOrigenId: null },
clonTradicional: { clonTradicional: {
archivoWordPlanId: null, archivoWordPlanId: null,
@@ -40,7 +36,6 @@ export function useNuevoPlanWizard() {
}, },
iaConfig: { iaConfig: {
descripcionEnfoque: "", descripcionEnfoque: "",
poblacionObjetivo: "",
notasAdicionales: "", notasAdicionales: "",
archivosReferencia: [], archivosReferencia: [],
repositoriosReferencia: [], repositoriosReferencia: [],
@@ -51,14 +46,10 @@ export function useNuevoPlanWizard() {
errorMessage: null, errorMessage: null,
}); });
const carrerasFiltradas = useMemo(() => { const canContinueDesdeModo = wizard.tipoOrigen === "MANUAL" ||
const fac = wizard.datosBasicos.facultadId; wizard.tipoOrigen === "IA" ||
return fac ? CARRERAS.filter((c) => c.facultadId === fac) : CARRERAS; (wizard.tipoOrigen === "CLONADO_INTERNO" ||
}, [wizard.datosBasicos.facultadId]); wizard.tipoOrigen === "CLONADO_TRADICIONAL");
const canContinueDesdeModo = wizard.modoCreacion === "MANUAL" ||
wizard.modoCreacion === "IA" ||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombrePlan && const canContinueDesdeBasicos = !!wizard.datosBasicos.nombrePlan &&
!!wizard.datosBasicos.carreraId && !!wizard.datosBasicos.carreraId &&
@@ -67,30 +58,25 @@ export function useNuevoPlanWizard() {
(wizard.datosBasicos.numCiclos !== undefined && (wizard.datosBasicos.numCiclos !== undefined &&
wizard.datosBasicos.numCiclos > 0) && wizard.datosBasicos.numCiclos > 0) &&
// Requerir ambas plantillas (plan y mapa) con versión // Requerir ambas plantillas (plan y mapa) con versión
!!wizard.datosBasicos.plantillaPlanId && !!wizard.datosBasicos.estructuraPlanId;
!!wizard.datosBasicos.plantillaPlanVersion &&
!!wizard.datosBasicos.plantillaMapaId &&
!!wizard.datosBasicos.plantillaMapaVersion;
const canContinueDesdeDetalles = (() => { const canContinueDesdeDetalles = (() => {
if (wizard.modoCreacion === "MANUAL") return true; if (wizard.tipoOrigen === "MANUAL") return true;
if (wizard.modoCreacion === "IA") { if (wizard.tipoOrigen === "IA") {
// Requerimos descripción del enfoque y notas adicionales // Requerimos descripción del enfoque y notas adicionales
return !!wizard.iaConfig?.descripcionEnfoque && return !!wizard.iaConfig?.descripcionEnfoque &&
!!wizard.iaConfig?.notasAdicionales; !!wizard.iaConfig.notasAdicionales;
} }
if (wizard.modoCreacion === "CLONADO") { if (wizard.tipoOrigen === "CLONADO_INTERNO") {
if (wizard.subModoClonado === "INTERNO") { return !!wizard.clonInterno?.planOrigenId;
return !!wizard.clonInterno?.planOrigenId; }
} if (wizard.tipoOrigen === "CLONADO_TRADICIONAL") {
if (wizard.subModoClonado === "TRADICIONAL") { const t = wizard.clonTradicional;
const t = wizard.clonTradicional; if (!t) return false;
if (!t) return false; const tieneWord = !!t.archivoWordPlanId;
const tieneWord = !!t.archivoWordPlanId; const tieneAlMenosUnExcel = !!t.archivoMapaExcelId ||
const tieneAlMenosUnExcel = !!t.archivoMapaExcelId || !!t.archivoAsignaturasExcelId;
!!t.archivoAsignaturasExcelId; return tieneWord && tieneAlMenosUnExcel;
return tieneWord && tieneAlMenosUnExcel;
}
} }
return false; return false;
})(); })();
@@ -101,7 +87,7 @@ export function useNuevoPlanWizard() {
// Ensure preview has the stricter types required by `PlanPreview`. // Ensure preview has the stricter types required by `PlanPreview`.
let tipoCicloSafe: TipoCiclo; let tipoCicloSafe: TipoCiclo;
if (wizard.datosBasicos.tipoCiclo === "") { if (wizard.datosBasicos.tipoCiclo === "") {
tipoCicloSafe = "SEMESTRE"; tipoCicloSafe = "Semestre";
} else { } else {
tipoCicloSafe = wizard.datosBasicos.tipoCiclo; tipoCicloSafe = wizard.datosBasicos.tipoCiclo;
} }
@@ -112,7 +98,7 @@ export function useNuevoPlanWizard() {
const preview: PlanPreview = { const preview: PlanPreview = {
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre", nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
nivel: wizard.datosBasicos.nivel || "Licenciatura", nivel: wizard.datosBasicos.nivel as NivelPlanEstudio,
tipoCiclo: tipoCicloSafe, tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe, numCiclos: numCiclosSafe,
numAsignaturasAprox: numCiclosSafe * 6, numAsignaturasAprox: numCiclosSafe * 6,
@@ -121,7 +107,7 @@ export function useNuevoPlanWizard() {
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" }, { id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
], ],
}; };
setWizard((w) => ({ setWizard((w: NewPlanWizardState) => ({
...w, ...w,
isLoading: false, isLoading: false,
resumen: { previewPlan: preview }, resumen: { previewPlan: preview },
@@ -131,7 +117,6 @@ export function useNuevoPlanWizard() {
return { return {
wizard, wizard,
setWizard, setWizard,
carrerasFiltradas,
canContinueDesdeModo, canContinueDesdeModo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeDetalles, canContinueDesdeDetalles,

View File

@@ -1,10 +1,13 @@
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE"; import type { UploadedFile } from "@/components/planes/wizard/PasoDetallesPanel/FileDropZone";
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO"; import type {
export type SubModoClonado = "INTERNO" | "TRADICIONAL"; NivelPlanEstudio,
TipoCiclo,
TipoOrigen,
} from "@/data/types/domain";
export type PlanPreview = { export type PlanPreview = {
nombrePlan: string; nombrePlan: string;
nivel: string; nivel: NivelPlanEstudio;
tipoCiclo: TipoCiclo; tipoCiclo: TipoCiclo;
numCiclos: number; numCiclos: number;
numAsignaturasAprox?: number; numAsignaturasAprox?: number;
@@ -13,20 +16,16 @@ export type PlanPreview = {
export type NewPlanWizardState = { export type NewPlanWizardState = {
step: 1 | 2 | 3 | 4; step: 1 | 2 | 3 | 4;
modoCreacion: ModoCreacion | null; tipoOrigen: TipoOrigen | null;
subModoClonado?: SubModoClonado;
datosBasicos: { datosBasicos: {
nombrePlan: string; nombrePlan: string;
carreraId: string; carreraId: string;
facultadId: string; facultadId: string;
nivel: string; nivel: NivelPlanEstudio | "";
tipoCiclo: TipoCiclo | ""; tipoCiclo: TipoCiclo | "";
numCiclos: number | undefined; numCiclos: number | undefined;
// Selección de plantillas (obligatorias) // Selección de plantillas (obligatorias)
plantillaPlanId?: string; estructuraPlanId: string | null;
plantillaPlanVersion?: string;
plantillaMapaId?: string;
plantillaMapaVersion?: string;
}; };
clonInterno?: { planOrigenId: string | null }; clonInterno?: { planOrigenId: string | null };
clonTradicional?: { clonTradicional?: {
@@ -53,12 +52,11 @@ export type NewPlanWizardState = {
}; };
iaConfig?: { iaConfig?: {
descripcionEnfoque: string; descripcionEnfoque: string;
poblacionObjetivo: string;
notasAdicionales: string; notasAdicionales: string;
archivosReferencia: Array<string>; archivosReferencia: Array<string>;
repositoriosReferencia?: Array<string>; repositoriosReferencia?: Array<string>;
archivosAdjuntos?: Array< archivosAdjuntos?: Array<
{ id: string; name: string; size: string; type: string } UploadedFile
>; >;
}; };
resumen: { previewPlan?: PlanPreview }; resumen: { previewPlan?: PlanPreview };

View File

@@ -0,0 +1,5 @@
export const 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";
};

View File

@@ -0,0 +1,10 @@
// src/features/planes/utils/icon-utils.ts
import * as Icons from "lucide-react";
import { BookOpen } from "lucide-react";
export const getIconByName = (iconName: string | null) => {
if (!iconName) return BookOpen;
// "as any" es necesario aquí porque el string es dinámico
const Icon = (Icons as any)[iconName];
return Icon || BookOpen;
};

View File

@@ -2,10 +2,11 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import * as TanStackQueryProvider from './integrations/tanstack-query/root-provider.tsx'
import reportWebVitals from './reportWebVitals.ts' import reportWebVitals from './reportWebVitals.ts'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
import './styles.css' import './styles.css'
// Create a new router instance // Create a new router instance

View File

@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as PlanesPlanesListRouteRouteImport } from './routes/planes/PlanesListRoute'
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query' import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route' import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo' import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
@@ -44,6 +45,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const PlanesPlanesListRouteRoute = PlanesPlanesListRouteRouteImport.update({
id: '/planes/PlanesListRoute',
path: '/planes/PlanesListRoute',
getParentRoute: () => rootRouteImport,
} as any)
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({ const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
id: '/demo/tanstack-query', id: '/demo/tanstack-query',
path: '/demo/tanstack-query', path: '/demo/tanstack-query',
@@ -142,6 +148,7 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/planes': typeof PlanesListaRouteRouteWithChildren '/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren '/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren '/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
@@ -162,6 +169,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/planes': typeof PlanesListaRouteRouteWithChildren '/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren '/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
@@ -182,6 +190,7 @@ export interface FileRoutesById {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren '/planes/_lista': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren '/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren '/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute '/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
@@ -205,6 +214,7 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/planes' | '/planes'
| '/demo/tanstack-query' | '/demo/tanstack-query'
| '/planes/PlanesListRoute'
| '/planes/$planId' | '/planes/$planId'
| '/planes/$planId/asignaturas' | '/planes/$planId/asignaturas'
| '/planes/nuevo' | '/planes/nuevo'
@@ -225,6 +235,7 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/planes' | '/planes'
| '/demo/tanstack-query' | '/demo/tanstack-query'
| '/planes/PlanesListRoute'
| '/planes/$planId' | '/planes/$planId'
| '/planes/nuevo' | '/planes/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/asignaturas/$asignaturaId'
@@ -244,6 +255,7 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/planes/_lista' | '/planes/_lista'
| '/demo/tanstack-query' | '/demo/tanstack-query'
| '/planes/PlanesListRoute'
| '/planes/$planId/_detalle' | '/planes/$planId/_detalle'
| '/planes/$planId/asignaturas' | '/planes/$planId/asignaturas'
| '/planes/_lista/nuevo' | '/planes/_lista/nuevo'
@@ -266,6 +278,7 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
PlanesPlanesListRouteRoute: typeof PlanesPlanesListRouteRoute
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
} }
@@ -293,6 +306,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/planes/PlanesListRoute': {
id: '/planes/PlanesListRoute'
path: '/planes/PlanesListRoute'
fullPath: '/planes/PlanesListRoute'
preLoaderRoute: typeof PlanesPlanesListRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/tanstack-query': { '/demo/tanstack-query': {
id: '/demo/tanstack-query' id: '/demo/tanstack-query'
path: '/demo/tanstack-query' path: '/demo/tanstack-query'
@@ -486,6 +506,7 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren, PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
DemoTanstackQueryRoute: DemoTanstackQueryRoute, DemoTanstackQueryRoute: DemoTanstackQueryRoute,
PlanesPlanesListRouteRoute: PlanesPlanesListRouteRoute,
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren, PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
PlanesPlanIdAsignaturasRouteRoute: PlanesPlanIdAsignaturasRouteRoute:
PlanesPlanIdAsignaturasRouteRouteWithChildren, PlanesPlanIdAsignaturasRouteRouteWithChildren,

View File

@@ -30,4 +30,29 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
/> />
</> </>
), ),
errorComponent: ({ error, reset }) => {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center space-y-4 p-6 text-center">
<h2 className="text-2xl font-bold text-red-600">
¡Ups! Algo salió mal
</h2>
<p className="max-w-md text-gray-600">
Ocurrió un error inesperado al cargar esta sección.
</p>
{/* Opcional: Mostrar el detalle técnico en desarrollo */}
<pre className="max-w-full overflow-auto rounded border border-gray-300 bg-gray-100 p-4 text-left text-xs">
{error.message}
</pre>
<button
onClick={reset}
className="rounded bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700"
>
Intentar de nuevo
</button>
</div>
)
},
}) })

View File

@@ -0,0 +1,42 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { usePlanes } from '@/data'
export const Route = createFileRoute('/planes/PlanesListRoute')({
component: RouteComponent,
})
function RouteComponent() {
const [search, setSearch] = useState('')
const filters = useMemo(
() => ({ search, limit: 20, offset: 0, activo: true }),
[search],
)
const { data, isLoading, isError, error } = usePlanes(filters)
return (
<div style={{ padding: 16 }}>
<h1>Planes</h1>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar…"
/>
{isLoading && <div>Cargando</div>}
{isError && <div>Error: {(error as any).message}</div>}
<ul>
{(data?.data ?? []).map((p) => (
<li key={p.id}>
<pre>{JSON.stringify(p, null, 2)}</pre>
</li>
))}
</ul>
</div>
)
}

View File

@@ -1,12 +1,15 @@
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router' import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useMemo, useState } from 'react' import { useState, useMemo } from 'react'
import { useDebounce } from 'use-debounce'
import type { Option } from '@/components/planes/Filtro'
// Componentes
import BarraBusqueda from '@/components/planes/BarraBusqueda' import BarraBusqueda from '@/components/planes/BarraBusqueda'
import Filtro from '@/components/planes/Filtro' import Filtro from '@/components/planes/Filtro'
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard' import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
// Hooks y Utils (ajusta las rutas de importación)
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
import { getIconByName } from '@/features/planes/utils/icon-utils'
export const Route = createFileRoute('/planes/_lista')({ export const Route = createFileRoute('/planes/_lista')({
component: RouteComponent, component: RouteComponent,
@@ -14,215 +17,104 @@ export const Route = createFileRoute('/planes/_lista')({
function RouteComponent() { function RouteComponent() {
const navigate = useNavigate() const navigate = useNavigate()
type Facultad = { id: string; nombre: string; color: string }
type Carrera = { id: string; nombre: string; facultadId: string }
type Plan = {
id: string
icon: string
nombrePrograma: string
nivel: string
ciclos: string
facultadId: string
carreraId: string
estado:
| 'Aprobado'
| 'Pendiente'
| 'En proceso'
| 'Revisión expertos'
| 'Actualización'
claseColorEstado: string
}
// Simulación: datos provenientes de Supabase (hardcode) // 1. Estados de Filtros
const facultades: Array<Facultad> = [
{ id: 'ing', nombre: 'Facultad de Ingeniería', color: '#2563eb' },
{ id: 'med', nombre: 'Facultad de Medicina', color: '#dc2626' },
{ id: 'neg', nombre: 'Facultad de Negocios', color: '#059669' },
{
id: 'arq',
nombre: 'Facultad Mexicana de Arquitectura, Diseño y Comunicación',
color: '#ea580c',
},
{
id: 'sal',
nombre: 'Escuela de Altos Estudios en Salud',
color: '#0891b2',
},
{ id: 'der', nombre: 'Facultad de Derecho', color: '#7c3aed' },
{ id: 'qui', nombre: 'Facultad de Ciencias Químicas', color: '#65a30d' },
]
const carreras: Array<Carrera> = [
{
id: 'sis',
nombre: 'Ingeniería en Sistemas Computacionales',
facultadId: 'ing',
},
{ id: 'medico', nombre: 'Médico Cirujano', facultadId: 'med' },
{ id: 'act', nombre: 'Licenciatura en Actuaría', facultadId: 'neg' },
{ id: 'arq', nombre: 'Licenciatura en Arquitectura', facultadId: 'arq' },
{ id: 'fisio', nombre: 'Licenciatura en Fisioterapia', facultadId: 'sal' },
{ id: 'der', nombre: 'Licenciatura en Derecho', facultadId: 'der' },
{ id: 'qfb', nombre: 'Químico Farmacéutico Biólogo', facultadId: 'qui' },
]
const estados: Array<Option> = [
{ value: 'todos', label: 'Todos los estados' },
{ value: 'Aprobado', label: 'Aprobado' },
{ value: 'Pendiente', label: 'Pendiente' },
{ value: 'En proceso', label: 'En proceso' },
{ value: 'Revisión expertos', label: 'Revisión expertos' },
{ value: 'Actualización', label: 'Actualización' },
]
const planes: Array<Plan> = [
{
id: 'p1',
icon: 'Laptop',
nombrePrograma: 'Ingeniería en Sistemas Computacionales',
nivel: 'Licenciatura',
ciclos: '8 semestres',
facultadId: 'ing',
carreraId: 'sis',
estado: 'Revisión expertos',
claseColorEstado: 'bg-amber-600',
},
{
id: 'p2',
icon: 'Stethoscope',
nombrePrograma: 'Médico Cirujano',
nivel: 'Licenciatura',
ciclos: '10 semestres',
facultadId: 'med',
carreraId: 'medico',
estado: 'Aprobado',
claseColorEstado: 'bg-emerald-600',
},
{
id: 'p3',
icon: 'Calculator',
nombrePrograma: 'Licenciatura en Actuaría',
nivel: 'Licenciatura',
ciclos: '9 semestres',
facultadId: 'neg',
carreraId: 'act',
estado: 'Aprobado',
claseColorEstado: 'bg-emerald-600',
},
{
id: 'p4',
icon: 'PencilRuler',
nombrePrograma: 'Licenciatura en Arquitectura',
nivel: 'Licenciatura',
ciclos: '10 semestres',
facultadId: 'arq',
carreraId: 'arq',
estado: 'En proceso',
claseColorEstado: 'bg-orange-500',
},
{
id: 'p5',
icon: 'Activity',
nombrePrograma: 'Licenciatura en Fisioterapia',
nivel: 'Licenciatura',
ciclos: '8 semestres',
facultadId: 'sal',
carreraId: 'fisio',
estado: 'Revisión expertos',
claseColorEstado: 'bg-amber-600',
},
{
id: 'p6',
icon: 'Scale',
nombrePrograma: 'Licenciatura en Derecho',
nivel: 'Licenciatura',
ciclos: '10 semestres',
facultadId: 'der',
carreraId: 'der',
estado: 'Pendiente',
claseColorEstado: 'bg-yellow-500',
},
{
id: 'p7',
icon: 'FlaskConical',
nombrePrograma: 'Químico Farmacéutico Biólogo',
nivel: 'Licenciatura',
ciclos: '9 semestres',
facultadId: 'qui',
carreraId: 'qfb',
estado: 'Actualización',
claseColorEstado: 'bg-lime-600',
},
]
// Estado de filtros
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
// Debounce para evitar llamadas excesivas a la API
const [debouncedSearch] = useDebounce(search, 500)
const [facultadSel, setFacultadSel] = useState<string>('todas') const [facultadSel, setFacultadSel] = useState<string>('todas')
const [carreraSel, setCarreraSel] = useState<string>('todas') const [carreraSel, setCarreraSel] = useState<string>('todas')
const [estadoSel, setEstadoSel] = useState<string>('todos') const [estadoSel, setEstadoSel] = useState<string>('todos')
// Opciones para filtros // Paginación (opcional si la implementas en UI)
const facultadesOptions: Array<Option> = useMemo( const [page, setPage] = useState(0)
const pageSize = 12
// 2. Carga de datos remotos
const { data: catalogos } = useCatalogosPlanes()
// Limpiamos el texto de búsqueda (quitar acentos) para enviarlo limpio a la API
// O lo puedes limpiar en el servicio. Aquí lo enviamos tal cual viene del debounce.
// Nota: Si usaste la solución "unaccent" en BD, envía el texto tal cual, postgres lo maneja.
const cleanSearchTerm = debouncedSearch.trim()
const {
data: planesData,
isLoading,
isError,
} = usePlanes({
search: cleanSearchTerm,
facultadId: facultadSel,
carreraId: carreraSel,
estadoId: estadoSel,
limit: pageSize,
offset: page * pageSize,
})
// 3. Preparación de Opciones para Selects (Derived State)
const facultadesOptions = useMemo(
() => [ () => [
{ value: 'todas', label: 'Todas las facultades' }, { value: 'todas', label: 'Todas las facultades' },
...facultades.map((f) => ({ value: f.id, label: f.nombre })), ...(catalogos?.facultades.map((f) => ({
value: f.id,
label: f.nombre,
})) ?? []),
], ],
[facultades], [catalogos?.facultades],
) )
const carrerasOptions: Array<Option> = useMemo(() => { const carrerasOptions = useMemo(() => {
const list = // Filtramos las carreras del catálogo base según la facultad seleccionada
const rawCarreras = catalogos?.carreras ?? []
const filtered =
facultadSel === 'todas' facultadSel === 'todas'
? carreras ? rawCarreras
: carreras.filter((c) => c.facultadId === facultadSel) : rawCarreras.filter((c) => c.facultad_id === facultadSel)
return [ return [
{ value: 'todas', label: 'Todas las carreras' }, { value: 'todas', label: 'Todas las carreras' },
...list.map((c) => ({ value: c.id, label: c.nombre })), ...filtered.map((c) => ({ value: c.id, label: c.nombre })),
] ]
}, [carreras, facultadSel]) }, [catalogos?.carreras, facultadSel])
// Filtrado de planes const estadosOptions = useMemo(
const filteredPlans = useMemo(() => { () => [
// Función helper para limpiar texto (quita acentos y hace minúsculas) { value: 'todos', label: 'Todos los estados' },
const cleanText = (text: string) => { ...(catalogos?.estados.map((e) => ({ value: e.id, label: e.etiqueta })) ??
return text []),
.normalize('NFD') // Descompone "á" en "a" + "´" ],
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos [catalogos?.estados],
.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
? // Limpiamos también el nombre del programa antes de comparar
cleanText(p.nombrePrograma).includes(term)
: true
const matchFac =
facultadSel === 'todas' ? true : p.facultadId === facultadSel
const matchCar =
carreraSel === 'todas' ? true : p.carreraId === carreraSel
const matchEst = estadoSel === 'todos' ? true : p.estado === estadoSel
return matchName && matchFac && matchCar && matchEst
})
}, [planes, search, facultadSel, carreraSel, estadoSel])
// 4. Handlers
const resetFilters = () => { const resetFilters = () => {
setSearch('') setSearch('')
setFacultadSel('todas') setFacultadSel('todas')
setCarreraSel('todas') setCarreraSel('todas')
setEstadoSel('todos') setEstadoSel('todos')
setPage(0)
} }
const handleSearchChange = (val: string) => {
setSearch(val)
setPage(0) // Resetear página al buscar
}
// Renderizado condicional básico
if (isError)
return <div className="p-8 text-red-500">Error cargando planes.</div>
return ( return (
<main className="bg-background min-h-screen w-full"> <main className="bg-background min-h-screen w-full">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
<div className="flex flex-col gap-4 lg:col-span-3"> <div className="flex flex-col gap-4 lg:col-span-3">
{/* Header y Botón Nuevo */}
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-xl"> <div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-xl">
<Icons.BookOpenText className="h-5 w-5" strokeWidth={2} /> <Icons.BookOpenText className="h-5 w-5" strokeWidth={2} />
</div> </div>
<div> <div>
<h1 className="font-display text-foreground text-2xl font-bold"> <h1 className="font-display text-foreground text-2xl font-bold">
Planes de Estudio Planes de Estudio
@@ -232,31 +124,23 @@ function RouteComponent() {
</p> </p>
</div> </div>
</div> </div>
<button <button
type="button" onClick={() => navigate({ to: '/planes/nuevo' })}
className={ className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
'ring-offset-background focus-visible:ring-ring bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium whitespace-nowrap shadow-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0'
}
aria-label="Nuevo plan de estudios"
title="Nuevo plan de estudios"
onClick={() => {
navigate({ to: '/planes/nuevo' })
}}
> >
<Icons.Plus className="" /> <Icons.Plus /> Nuevo plan de estudios
Nuevo plan de estudios
</button> </button>
</div> </div>
{/* Barra de Filtros */}
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center"> <div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<BarraBusqueda <BarraBusqueda
value={search} value={search}
onChange={setSearch} onChange={handleSearchChange}
placeholder="Buscar por programa" placeholder="Buscar por programa..."
/> />
</div> </div>
<div className="flex flex-col items-stretch justify-between gap-2 lg:flex-row lg:items-center"> <div className="flex flex-col items-stretch justify-between gap-2 lg:flex-row lg:items-center">
<div className="w-full lg:w-44"> <div className="w-full lg:w-44">
<Filtro <Filtro
@@ -264,67 +148,93 @@ function RouteComponent() {
value={facultadSel} value={facultadSel}
onChange={(v) => { onChange={(v) => {
setFacultadSel(v) setFacultadSel(v)
// Reset carrera si ya no pertenece
setCarreraSel('todas') setCarreraSel('todas')
setPage(0)
}} }}
placeholder="Facultad" placeholder="Facultad"
ariaLabel="Filtro por facultad"
/> />
</div> </div>
<div className="w-full lg:w-44"> <div className="w-full lg:w-44">
<Filtro <Filtro
options={carrerasOptions} options={carrerasOptions}
value={carreraSel} value={carreraSel}
onChange={setCarreraSel} onChange={(v) => {
setCarreraSel(v)
setPage(0)
}}
placeholder="Carrera" placeholder="Carrera"
ariaLabel="Filtro por carrera"
disabled={facultadSel === 'todas'} disabled={facultadSel === 'todas'}
/> />
</div> </div>
<div className="w-full lg:w-44"> <div className="w-full lg:w-44">
<Filtro <Filtro
options={estados} options={estadosOptions}
value={estadoSel} value={estadoSel}
onChange={setEstadoSel} onChange={(v) => {
setEstadoSel(v)
setPage(0)
}}
placeholder="Estado" placeholder="Estado"
ariaLabel="Filtro por estado"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={resetFilters} onClick={resetFilters}
className={ className="ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors"
'ring-offset-background focus-visible:ring-ring bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium whitespace-nowrap shadow-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50'
}
title="Reiniciar filtros"
aria-label="Reiniciar filtros"
> >
<Icons.X className="h-4 w-4" /> <Icons.X className="h-4 w-4" /> Limpiar
Limpiar
</button> </button>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredPlans.map((p) => { {/* Grid de Resultados */}
const fac = facultades.find((f) => f.id === p.facultadId)! {isLoading ? (
const IconComp = (Icons as any)[p.icon] ?? Icons.BookOpenText <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
return ( {/* Skeleton básico o Spinner */}
<PlanEstudiosCard {[...Array(8)].map((_, i) => (
key={p.id} <div
Icono={IconComp} key={i}
nombrePrograma={p.nombrePrograma} className="h-64 w-full animate-pulse rounded-xl bg-gray-100/50"
nivel={p.nivel}
ciclos={p.ciclos}
facultad={fac.nombre}
estado={p.estado}
claseColorEstado={p.claseColorEstado}
colorFacultad={fac.color}
onClick={() => console.log('Ver', p.nombrePrograma)}
/> />
) ))}
})} </div>
</div> ) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{planesData?.data.map((plan) => {
// Mapeo de datos: DB -> Props Componente
const facultad = plan.carreras?.facultades
const estado = plan.estados_plan
// NOTA: El color del estado no viene en BD por defecto,
// puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan'
// Aquí uso un fallback simple.
const estadoColor = estado?.es_final
? 'bg-emerald-600'
: 'bg-amber-600'
return (
<PlanEstudiosCard
key={plan.id}
Icono={getIconByName(facultad?.icono ?? null)}
nombrePrograma={plan.nombre}
nivel={plan.nivel}
ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`}
facultad={facultad?.nombre ?? 'Sin Facultad'}
estado={estado?.etiqueta ?? 'Desconocido'}
claseColorEstado={estadoColor}
colorFacultad={facultad?.color ?? '#000000'}
onClick={() => console.log('Ver plan', plan.id)}
/>
)
})}
{planesData?.data.length === 0 && (
<div className="text-muted-foreground col-span-full py-10 text-center">
No se encontraron planes con estos filtros.
</div>
)}
</div>
)}
</div> </div>
<Outlet /> <Outlet />
</div> </div>

1293
src/types/supabase.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
v2.67.1

View File

@@ -0,0 +1 @@
v2.184.1

View File

@@ -0,0 +1 @@
postgresql://postgres.exdkssurzmjnnhgtiama@aws-0-us-west-1.pooler.supabase.com:5432/postgres

View File

@@ -0,0 +1 @@
15.8.1.085

View File

@@ -0,0 +1 @@
exdkssurzmjnnhgtiama

View File

@@ -0,0 +1 @@
v12.2.3

View File

@@ -0,0 +1 @@
buckets-objects-grants-postgres

View File

@@ -0,0 +1 @@
v1.33.0