diff --git a/Dockerfile b/Dockerfile index 7a81091..badf8c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM oven/bun:1 AS build WORKDIR /app COPY . . -RUN bun install --frozen-lockfile +RUN bun install RUN bunx --bun vite build FROM nginx:alpine diff --git a/eslint.config.js b/eslint.config.js index 9bd9ca1..a51352b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, ] diff --git a/package.json b/package.json index fce2a72..5449226 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", - "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -50,13 +50,15 @@ "react-dom": "^19.2.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.0.6", - "tw-animate-css": "^1.3.6" + "tw-animate-css": "^1.3.6", + "use-debounce": "^10.1.0" }, "devDependencies": { "@tanstack/devtools-vite": "^0.3.11", "@tanstack/eslint-config": "^0.3.0", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", + "@types/bun": "^1.3.6", "@types/node": "^22.10.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", @@ -70,6 +72,7 @@ "jsdom": "^27.0.0", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.7.2", + "supabase": "^2.72.2", "typescript": "^5.7.2", "vite": "^7.1.7", "vitest": "^3.0.5", diff --git a/scripts/update-types.ts b/scripts/update-types.ts new file mode 100644 index 0000000..30ad115 --- /dev/null +++ b/scripts/update-types.ts @@ -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); +} diff --git a/src/components/asignaturas/wizard/PasoConfiguracionPanel.tsx b/src/components/asignaturas/wizard/PasoConfiguracionPanel.tsx index a6e8429..c81fb77 100644 --- a/src/components/asignaturas/wizard/PasoConfiguracionPanel.tsx +++ b/src/components/asignaturas/wizard/PasoConfiguracionPanel.tsx @@ -67,7 +67,7 @@ export function PasoConfiguracionPanel({ }, })) } - className="min-h-[100px]" + className="min-h-25" />
@@ -213,7 +213,7 @@ export function PasoConfiguracionPanel({
-
+
{MATERIAS_MOCK.map((m) => (
- +

{desc}

diff --git a/src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx b/src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx index bdc6b97..f17fa32 100644 --- a/src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx +++ b/src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx @@ -1,6 +1,9 @@ -import { TemplateSelectorCard } from './TemplateSelectorCard' - -import type { CARRERAS } from '@/features/planes/nuevo/catalogs' +import type { + EstructuraPlanRow, + FacultadRow, + NivelPlanEstudio, + TipoCiclo, +} from '@/data/types/domain' import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import { Input } from '@/components/ui/input' @@ -12,25 +15,30 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Separator } from '@/components/ui/separator' -import { - FACULTADES, - NIVELES, - TIPOS_CICLO, - PLANTILLAS_ANEXO_1, - PLANTILLAS_ANEXO_2, -} from '@/features/planes/nuevo/catalogs' +import { useCatalogosPlanes } from '@/data/hooks/usePlans' +import { NIVELES, TIPOS_CICLO } from '@/features/planes/nuevo/catalogs' import { cn } from '@/lib/utils' export function PasoBasicosForm({ wizard, onChange, - carrerasFiltradas, }: { wizard: NewPlanWizardState onChange: React.Dispatch> - 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 (
@@ -40,13 +48,18 @@ export function PasoBasicosForm({ ) => - onChange((w) => ({ - ...w, - datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value }, - })) + onChange( + (w): NewPlanWizardState => ({ + ...w, + datosBasicos: { + ...w.datosBasicos, + nombrePlan: e.target.value, + }, + }), + ) } className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic" /> @@ -57,14 +70,16 @@ export function PasoBasicosForm({ - onChange((w) => ({ - ...w, - datosBasicos: { ...w.datosBasicos, carreraId: value }, - })) + onChange( + (w): NewPlanWizardState => ({ + ...w, + datosBasicos: { ...w.datosBasicos, carreraId: value }, + }), + ) } disabled={!wizard.datosBasicos.facultadId} > @@ -112,7 +129,7 @@ export function PasoBasicosForm({ - {carrerasFiltradas.map((c) => ( + {filteredCarreras.map((c: any) => ( {c.nombre} @@ -125,11 +142,13 @@ export function PasoBasicosForm({ - onChange((w) => ({ - ...w, - datosBasicos: { - ...w.datosBasicos, - tipoCiclo: value as any, - }, - })) + onValueChange={(value: TipoCiclo) => + onChange( + (w): NewPlanWizardState => ({ + ...w, + datosBasicos: { + ...w.datosBasicos, + tipoCiclo: value as any, + }, + }), + ) } > {TIPOS_CICLO.map((t) => ( - - {t.label} + + {t} ))} @@ -196,22 +217,63 @@ export function PasoBasicosForm({ min={1} value={wizard.datosBasicos.numCiclos ?? ''} onChange={(e: React.ChangeEvent) => - onChange((w) => ({ - ...w, - datosBasicos: { - ...w.datosBasicos, - // Keep undefined when the input is empty so the field stays optional - numCiclos: - e.target.value === '' ? undefined : Number(e.target.value), - }, - })) + onChange( + (w): NewPlanWizardState => ({ + ...w, + datosBasicos: { + ...w.datosBasicos, + // Keep undefined when the input is empty so the field stays optional + numCiclos: + e.target.value === '' + ? undefined + : Number(e.target.value), + }, + }), + ) } className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic" placeholder="Ej. 8" />
+ +
+ + +
- + {/*
-
+
*/}
) } diff --git a/src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx b/src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx index 65b9d83..2e70ae1 100644 --- a/src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx +++ b/src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx @@ -2,13 +2,13 @@ import { Upload, File, X, FileText } from 'lucide-react' import { useState, useCallback, useEffect, useRef } from 'react' import { Button } from '@/components/ui/button' +import { formatFileSize } from '@/features/planes/utils/format-file-size' import { cn } from '@/lib/utils' -interface UploadedFile { - id: string - name: string - size: string - type: string +export interface UploadedFile { + id: string // Necesario para React (key) + file: File // La fuente de verdad (contiene name, size, type) + preview?: string // Opcional: si fueran imágenes } interface FileDropzoneProps { @@ -37,9 +37,7 @@ export function FileDropzone({ typeof crypto !== 'undefined' && 'randomUUID' in crypto ? (crypto as any).randomUUID() : `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - name: file.name, - size: formatFileSize(file.size), - type: file.name.split('.').pop() || 'file', + file, })) setFiles((prev) => { const room = Math.max(0, maxFiles - prev.length) @@ -97,12 +95,6 @@ export function FileDropzone({ if (onFilesChangeRef.current) onFilesChangeRef.current(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) => { switch (type.toLowerCase()) { case 'pdf': @@ -170,23 +162,25 @@ export function FileDropzone({ {/* Uploaded files list */} {files.length > 0 && (
- {files.map((file) => ( + {files.map((item) => (
- {getFileIcon(file.type)} + {getFileIcon(item.file.type)}

- {file.name} + {item.file.name} +

+

+ {formatFileSize(item.file.size)}

-

{file.size}

diff --git a/src/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel.tsx b/src/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel.tsx index ba3347e..b538938 100644 --- a/src/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel.tsx +++ b/src/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel.tsx @@ -1,6 +1,7 @@ import { FileDropzone } from './FileDropZone' import ReferenciasParaIA from './ReferenciasParaIA' +import type { UploadedFile } from './FileDropZone' import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import { Button } from '@/components/ui/button' @@ -30,7 +31,7 @@ export function PasoDetallesPanel({ onGenerarIA: () => void isLoading: boolean }) { - if (wizard.modoCreacion === 'MANUAL') { + if (wizard.tipoOrigen === 'MANUAL') { return ( @@ -43,7 +44,7 @@ export function PasoDetallesPanel({ ) } - if (wizard.modoCreacion === 'IA') { + if (wizard.tipoOrigen === 'IA') { return (
@@ -116,14 +117,16 @@ export function PasoDetallesPanel({ } }) } - onFilesChange={(files) => - onChange((w) => ({ - ...w, - iaConfig: { - ...(w.iaConfig || ({} as any)), - archivosAdjuntos: files, - }, - })) + onFilesChange={(files: Array) => + onChange( + (w): NewPlanWizardState => ({ + ...w, + iaConfig: { + ...(w.iaConfig || ({} as any)), + archivosAdjuntos: files, + }, + }), + ) } />
@@ -162,10 +165,7 @@ export function PasoDetallesPanel({ ) } - if ( - wizard.modoCreacion === 'CLONADO' && - wizard.subModoClonado === 'INTERNO' - ) { + if (wizard.tipoOrigen === 'CLONADO_INTERNO') { return (
@@ -269,10 +269,7 @@ export function PasoDetallesPanel({ ) } - if ( - wizard.modoCreacion === 'CLONADO' && - wizard.subModoClonado === 'TRADICIONAL' - ) { + if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') { return (
diff --git a/src/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA.tsx b/src/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA.tsx index 3a68bbe..528e32f 100644 --- a/src/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA.tsx +++ b/src/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA.tsx @@ -5,6 +5,8 @@ import BarraBusqueda from '../../BarraBusqueda' import { FileDropzone } from './FileDropZone' +import type { UploadedFile } from './FileDropZone' + import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' import { @@ -27,9 +29,7 @@ const ReferenciasParaIA = ({ selectedRepositorioIds?: Array onToggleArchivo?: (id: string, checked: boolean) => void onToggleRepositorio?: (id: string, checked: boolean) => void - onFilesChange?: ( - files: Array<{ id: string; name: string; size: string; type: string }>, - ) => void + onFilesChange?: (files: Array) => void }) => { const [busquedaArchivos, setBusquedaArchivos] = useState('') const [busquedaRepositorios, setBusquedaRepositorios] = useState('') diff --git a/src/components/planes/wizard/PasoModoCardGroup.tsx b/src/components/planes/wizard/PasoModoCardGroup.tsx index e316f86..efde81b 100644 --- a/src/components/planes/wizard/PasoModoCardGroup.tsx +++ b/src/components/planes/wizard/PasoModoCardGroup.tsx @@ -1,10 +1,7 @@ import * as Icons from 'lucide-react' -import type { - NewPlanWizardState, - ModoCreacion, - SubModoClonado, -} from '@/features/planes/nuevo/types' +import type { TipoOrigen } from '@/data/types/domain' +import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import { Card, @@ -21,8 +18,7 @@ export function PasoModoCardGroup({ wizard: NewPlanWizardState onChange: React.Dispatch> }) { - const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m - const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s + const isSelected = (m: TipoOrigen) => wizard.tipoOrigen === m const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => { const key = e.key if ( @@ -41,19 +37,21 @@ export function PasoModoCardGroup({ - onChange((w) => ({ - ...w, - modoCreacion: 'MANUAL', - subModoClonado: undefined, - })) + onChange( + (w): NewPlanWizardState => ({ + ...w, + tipoOrigen: 'MANUAL', + }), + ) } onKeyDown={(e: React.KeyboardEvent) => handleKeyActivate(e, () => - onChange((w) => ({ - ...w, - modoCreacion: 'MANUAL', - subModoClonado: undefined, - })), + onChange( + (w): NewPlanWizardState => ({ + ...w, + tipoOrigen: 'MANUAL', + }), + ), ) } role="button" @@ -70,19 +68,21 @@ export function PasoModoCardGroup({ - onChange((w) => ({ - ...w, - modoCreacion: 'IA', - subModoClonado: undefined, - })) + onChange( + (w): NewPlanWizardState => ({ + ...w, + tipoOrigen: 'IA', + }), + ) } onKeyDown={(e: React.KeyboardEvent) => handleKeyActivate(e, () => - onChange((w) => ({ - ...w, - modoCreacion: 'IA', - subModoClonado: undefined, - })), + onChange( + (w): NewPlanWizardState => ({ + ...w, + tipoOrigen: 'IA', + }), + ), ) } role="button" @@ -99,11 +99,13 @@ export function PasoModoCardGroup({ onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))} + className={isSelected('OTRO') ? 'ring-ring ring-2' : ''} + onClick={() => + onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' })) + } onKeyDown={(e: React.KeyboardEvent) => handleKeyActivate(e, () => - onChange((w) => ({ ...w, modoCreacion: 'CLONADO' })), + onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' })), ) } role="button" @@ -115,22 +117,34 @@ export function PasoModoCardGroup({ Desde un plan existente o archivos. - {wizard.modoCreacion === 'CLONADO' && ( + {(wizard.tipoOrigen === 'OTRO' || + wizard.tipoOrigen === 'CLONADO_INTERNO' || + wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
{ e.stopPropagation() - onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })) + onChange( + (w): NewPlanWizardState => ({ + ...w, + tipoOrigen: 'CLONADO_INTERNO', + }), + ) }} onKeyDown={(e: React.KeyboardEvent) => 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 ${ - isSubSelected('INTERNO') + isSelected('CLONADO_INTERNO') ? 'border-primary bg-primary/5 ring-primary text-primary ring-1' : 'border-border text-muted-foreground' } `} @@ -144,15 +158,25 @@ export function PasoModoCardGroup({ tabIndex={0} onClick={(e) => { e.stopPropagation() - onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })) + onChange( + (w): NewPlanWizardState => ({ + ...w, + tipoOrigen: 'CLONADO_TRADICIONAL', + }), + ) }} onKeyDown={(e: React.KeyboardEvent) => 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 ${ - isSubSelected('TRADICIONAL') + isSelected('CLONADO_TRADICIONAL') ? 'border-primary bg-primary/5 ring-primary text-primary ring-1' : 'border-border text-muted-foreground' } `} diff --git a/src/components/planes/wizard/PasoResumenCard.tsx b/src/components/planes/wizard/PasoResumenCard.tsx index cc01586..cf4cc47 100644 --- a/src/components/planes/wizard/PasoResumenCard.tsx +++ b/src/components/planes/wizard/PasoResumenCard.tsx @@ -8,12 +8,11 @@ import { CardTitle, } from '@/components/ui/card' import { - PLANTILLAS_ANEXO_1, - PLANTILLAS_ANEXO_2, PLANES_EXISTENTES, ARCHIVOS, REPOSITORIOS, } from '@/features/planes/nuevo/catalogs' +import { formatFileSize } from '@/features/planes/utils/format-file-size' export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) { return ( @@ -32,12 +31,6 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) { const repositoriosRef = wizard.iaConfig?.repositoriosReferencia ?? [] const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? [] - const plantillaPlan = PLANTILLAS_ANEXO_1.find( - (x) => x.id === wizard.datosBasicos.plantillaPlanId, - ) - const plantillaMapa = PLANTILLAS_ANEXO_2.find( - (x) => x.id === wizard.datosBasicos.plantillaMapaId, - ) const contenido = ( <>
@@ -68,89 +61,56 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) { {wizard.datosBasicos.tipoCiclo})
-
- - Plantilla plan:{' '} - - - {(plantillaPlan?.name || - wizard.datosBasicos.plantillaPlanId || - '—') + - ' · ' + - (wizard.datosBasicos.plantillaPlanVersion || '—')} - -
-
- - Mapa curricular:{' '} - - - {(plantillaMapa?.name || - wizard.datosBasicos.plantillaMapaId || - '—') + - ' · ' + - (wizard.datosBasicos.plantillaMapaVersion || '—')} - -
Modo: - {wizard.modoCreacion === 'MANUAL' && 'Manual'} - {wizard.modoCreacion === 'IA' && 'Generado con IA'} - {wizard.modoCreacion === 'CLONADO' && - wizard.subModoClonado === 'INTERNO' && + {wizard.tipoOrigen === 'MANUAL' && 'Manual'} + {wizard.tipoOrigen === 'IA' && 'Generado con IA'} + {wizard.tipoOrigen === 'CLONADO_INTERNO' && 'Clonado desde plan del sistema'} - {wizard.modoCreacion === 'CLONADO' && - wizard.subModoClonado === 'TRADICIONAL' && + {wizard.tipoOrigen === 'CLONADO_TRADICIONAL' && 'Importado desde documentos tradicionales'}
- {wizard.modoCreacion === 'CLONADO' && - wizard.subModoClonado === 'INTERNO' && ( -
- - Plan origen:{' '} - - - {(() => { - const p = PLANES_EXISTENTES.find( - (x) => x.id === wizard.clonInterno?.planOrigenId, - ) - return ( - p?.nombre || wizard.clonInterno?.planOrigenId || '—' - ) - })()} - -
- )} - {wizard.modoCreacion === 'CLONADO' && - wizard.subModoClonado === 'TRADICIONAL' && ( -
-
Documentos adjuntos
-
    -
  • - - Word del plan: - {' '} - {wizard.clonTradicional?.archivoWordPlanId?.name || - '—'} -
  • -
  • - - Mapa curricular: - {' '} - {wizard.clonTradicional?.archivoMapaExcelId?.name || - '—'} -
  • -
  • - Asignaturas:{' '} - {wizard.clonTradicional?.archivoAsignaturasExcelId - ?.name || '—'} -
  • -
-
- )} - {wizard.modoCreacion === 'IA' && ( + {wizard.tipoOrigen === 'CLONADO_INTERNO' && ( +
+ Plan origen: + + {(() => { + const p = PLANES_EXISTENTES.find( + (x) => x.id === wizard.clonInterno?.planOrigenId, + ) + return ( + p?.nombre || wizard.clonInterno?.planOrigenId || '—' + ) + })()} + +
+ )} + {wizard.tipoOrigen === 'CLONADO_TRADICIONAL' && ( +
+
Documentos adjuntos
+
    +
  • + Word del plan:{' '} + {wizard.clonTradicional?.archivoWordPlanId?.name || '—'} +
  • +
  • + + Mapa curricular: + {' '} + {wizard.clonTradicional?.archivoMapaExcelId?.name || + '—'} +
  • +
  • + Asignaturas:{' '} + {wizard.clonTradicional?.archivoAsignaturasExcelId + ?.name || '—'} +
  • +
+
+ )} + {wizard.tipoOrigen === 'IA' && (
Enfoque: @@ -208,8 +168,10 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
    {adjuntos.map((f) => (
  • - {f.name}{' '} - · {f.size} + + {f.file.name} + {' '} + · {formatFileSize(f.file.size)}
  • ))}
diff --git a/src/components/planes/wizard/StepWithTooltip.tsx b/src/components/planes/wizard/StepWithTooltip.tsx index b45ace7..44f8e6e 100644 --- a/src/components/planes/wizard/StepWithTooltip.tsx +++ b/src/components/planes/wizard/StepWithTooltip.tsx @@ -32,7 +32,7 @@ export function StepWithTooltip({ {title} - +

{desc}

diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 37a7d4b..6a64f8b 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,52 +1,54 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from '@radix-ui/react-slot' +import { cva } 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( - "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: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: 'bg-primary text-primary-foreground hover:bg-primary/90', 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: - "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: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", + 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', }, size: { - 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", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-sm": "size-8", - "icon-lg": "size-10", + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + 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', + icon: 'size-9', + 'icon-sm': 'size-8', + 'icon-lg': 'size-10', }, }, defaultVariants: { - variant: "default", - size: "default", + variant: 'default', + size: 'default', }, - } + }, ) function Button({ className, - variant = "default", - size = "default", + variant = 'default', + size = 'default', asChild = false, ...props -}: React.ComponentProps<"button"> & +}: React.ComponentProps<'button'> & VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : 'button' return ( { + return text + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() +} + export async function plans_list( filters: PlanListFilters = {}, ): Promise> { 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 .from('planes_estudio') .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)), - estructuras_plan(id,nombre,tipo,version,definicion), - estados_plan(id,clave,etiqueta,orden,es_final) - `, + *, + carreras${carreraModifier} ( + *, + facultades (*) + ), + estructuras_plan (*), + estados_plan (*) + `, { count: 'exact' }, ) .order('actualizado_en', { ascending: false }) - if (filters.search?.trim()) - 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) + // 2. Aplicamos filtros dinámicos - // filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos) - if (filters.facultadId) q = q.eq('carreras.facultad_id', filters.facultadId) + // SOLUCIÓN SEARCH: Limpiamos el input y buscamos en la columna generada + 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) - 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 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, + count: count ?? 0, + } } export async function plans_get(planId: UUID): Promise { @@ -82,10 +128,10 @@ export async function plans_get(planId: UUID): Promise { .from('planes_estudio') .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)), - estructuras_plan(id,nombre,tipo,template_id,definicion), - estados_plan(id,clave,etiqueta,orden,es_final) + *, + carreras (*, facultades(*)), + estructuras_plan (*), + estados_plan (*) `, ) .eq('id', planId) @@ -95,7 +141,9 @@ export async function plans_get(planId: UUID): Promise { return requireData(data, 'Plan no encontrado.') } -export async function plan_lineas_list(planId: UUID): Promise { +export async function plan_lineas_list( + planId: UUID, +): Promise> { const supabase = supabaseBrowser() const { data, error } = await supabase .from('lineas_plan') @@ -109,7 +157,7 @@ export async function plan_lineas_list(planId: UUID): Promise { export async function plan_asignaturas_list( planId: UUID, -): Promise { +): Promise> { const supabase = supabaseBrowser() const { data, error } = await supabase .from('asignaturas') @@ -125,7 +173,7 @@ export async function plan_asignaturas_list( return data ?? [] } -export async function plans_history(planId: UUID): Promise { +export async function plans_history(planId: UUID): Promise> { const supabase = supabaseBrowser() const { data, error } = await supabase .from('cambios_plan') @@ -170,8 +218,9 @@ export type AIGeneratePlanInput = { descripcionEnfoque: string poblacionObjetivo?: string notasAdicionales?: string - archivosReferencia?: UUID[] - repositoriosIds?: UUID[] + archivosReferencia?: Array + repositoriosIds?: Array + archivosAdjuntos: Array usarMCP?: boolean } } @@ -246,12 +295,12 @@ export type PlanMapOperation = op: 'REORDER_CELDA' linea_plan_id: UUID numero_ciclo: number - asignaturaIdsOrdenados: UUID[] + asignaturaIdsOrdenados: Array } export async function plans_update_map( planId: UUID, - ops: PlanMapOperation[], + ops: Array, ): Promise<{ ok: true }> { 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( planId: UUID, ): Promise { - return invokeEdge(EDGE.plans_get_document, { planId }) + return invokeEdge(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 ?? [], + } } diff --git a/src/data/hooks/useAI.ts b/src/data/hooks/useAI.ts index 4911ddd..d2af82f 100644 --- a/src/data/hooks/useAI.ts +++ b/src/data/hooks/useAI.ts @@ -1,4 +1,5 @@ import { useMutation } from "@tanstack/react-query"; + import { ai_plan_chat, ai_plan_improve, diff --git a/src/data/hooks/usePlans.ts b/src/data/hooks/usePlans.ts index 50bc5ad..ab2bbc0 100644 --- a/src/data/hooks/usePlans.ts +++ b/src/data/hooks/usePlans.ts @@ -14,6 +14,7 @@ import type { } from '../api/plans.api' import { ai_generate_plan, + getCatalogos, plan_asignaturas_list, plan_lineas_list, plans_clone_from_existing, @@ -33,8 +34,13 @@ import { export function usePlanes(filters: PlanListFilters) { // 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable. return useQuery({ + // Usamos la factory de keys para consistencia queryKey: qk.planesList(filters), + + // La función fetch queryFn: () => plans_list(filters), + + // UX: Mantiene los datos viejos mientras carga la paginación nueva placeholderData: keepPreviousData, }) } diff --git a/src/data/query/queryClient.ts b/src/data/query/queryClient.ts deleted file mode 100644 index 6bf9c4a..0000000 --- a/src/data/query/queryClient.ts +++ /dev/null @@ -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, - }, - }, -}); diff --git a/src/integrations/tanstack-query/root-provider.tsx b/src/data/query/queryClient.tsx similarity index 58% rename from src/integrations/tanstack-query/root-provider.tsx rename to src/data/query/queryClient.tsx index 5a6ff08..3a8fd7e 100644 --- a/src/integrations/tanstack-query/root-provider.tsx +++ b/src/data/query/queryClient.tsx @@ -1,7 +1,20 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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 { queryClient, } diff --git a/src/data/supabase/client.ts b/src/data/supabase/client.ts index 6e707bc..4dca130 100644 --- a/src/data/supabase/client.ts +++ b/src/data/supabase/client.ts @@ -1,7 +1,10 @@ -import { createClient, type SupabaseClient } from "@supabase/supabase-js"; -import type { Database } from "../types/database"; +import { createClient } from "@supabase/supabase-js"; + import { getEnv } from "./env"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "src/types/supabase"; + let _client: SupabaseClient | null = null; export function supabaseBrowser(): SupabaseClient { @@ -10,13 +13,13 @@ export function supabaseBrowser(): SupabaseClient { const url = getEnv( "VITE_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_URL", - "SUPABASE_URL" + "SUPABASE_URL", ); const anonKey = getEnv( "VITE_SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_ANON_KEY", - "SUPABASE_ANON_KEY" + "SUPABASE_ANON_KEY", ); _client = createClient(url, anonKey, { diff --git a/src/data/types/database.ts b/src/data/types/database.ts deleted file mode 100644 index 4c0c381..0000000 --- a/src/data/types/database.ts +++ /dev/null @@ -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) diff --git a/src/data/types/domain.ts b/src/data/types/domain.ts index cb568a4..a66fdb7 100644 --- a/src/data/types/domain.ts +++ b/src/data/types/domain.ts @@ -1,29 +1,22 @@ -import type { Json } from "./database"; +import type { Enums, Tables } from "../../types/supabase"; export type UUID = string; -export type TipoEstructuraPlan = "CURRICULAR" | "NO_CURRICULAR"; -export type NivelPlanEstudio = - | "LICENCIATURA" - | "MAESTRIA" - | "DOCTORADO" - | "ESPECIALIDAD" - | "DIPLOMADO" - | "OTRO"; +export type TipoEstructuraPlan = Enums<"tipo_estructura_plan">; +export type NivelPlanEstudio = Enums<"nivel_plan_estudio">; +export type TipoCiclo = Enums<"tipo_ciclo">; -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 TipoFuenteBibliografia = "MANUAL" | "BIBLIOTECA"; +export type EstadoTareaRevision = Enums<"estado_tarea_revision">; +export type TipoNotificacion = Enums<"tipo_notificacion">; -export type EstadoTareaRevision = "PENDIENTE" | "COMPLETADA" | "OMITIDA"; -export type TipoNotificacion = "PLAN_ASIGNADO" | "ESTADO_CAMBIADO" | "TAREA_ASIGNADA" | "COMENTARIO" | "OTRA"; - -export type TipoInteraccionIA = "GENERAR" | "MEJORAR_SECCION" | "CHAT" | "OTRA"; +export type TipoInteraccionIA = Enums<"tipo_interaccion_ia">; export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta"; export type DisenoCurricular = "Rígido" | "Flexible"; @@ -58,218 +51,49 @@ export type PlanDatosSep = { propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null; }; -export type Paged = { 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 = { - id: UUID; - nombre: string; - nombre_corto: string | null; - color: string | null; - icono: string | null; - creado_en: string; - actualizado_en: string; +export type Paged = { data: Array; count: number | null }; + +export type FacultadRow = Tables<"facultades">; +export type CarreraRow = Tables<"carreras">; + +export type EstructuraPlanRow = Tables<"estructuras_plan">; + +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 = { - id: UUID; - facultad_id: UUID; - nombre: string; - nombre_corto: string | null; - clave_sep: string | null; - activa: boolean; - creado_en: string; - actualizado_en: string; +export type LineaPlan = Tables<"lineas_plan">; - facultades?: Facultad | null; -}; +export type Asignatura = Tables<"asignaturas">; -export type EstructuraPlan = { - id: UUID; - nombre: string; - tipo: TipoEstructuraPlan; - version: string | null; - definicion: Json; -}; +export type BibliografiaAsignatura = Tables<"bibliografia_asignatura">; -export type EstructuraAsignatura = { - id: UUID; - nombre: string; - version: string | null; - definicion: Json; -}; +export type CambioPlan = Tables<"cambios_plan">; -export type EstadoPlan = { - id: UUID; - clave: string; - etiqueta: string; - orden: number; - es_final: boolean; -}; +export type CambioAsignatura = Tables<"cambios_asignatura">; -export type PlanEstudio = { - id: UUID; - carrera_id: UUID; - estructura_id: UUID; +export type InteraccionIA = Tables<"interacciones_ia">; - nombre: string; - nivel: NivelPlanEstudio; - tipo_ciclo: TipoCiclo; - numero_ciclos: number; +export type TareaRevision = Tables<"tareas_revision">; - datos: Json; +export type Notificacion = Tables<"notificaciones">; - estado_actual_id: UUID | null; - 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; -}; +export type Archivo = Tables<"archivos">; diff --git a/src/features/planes/nuevo/NuevoPlanModalContainer.tsx b/src/features/planes/nuevo/NuevoPlanModalContainer.tsx index 2ca1e57..cb560fe 100644 --- a/src/features/planes/nuevo/NuevoPlanModalContainer.tsx +++ b/src/features/planes/nuevo/NuevoPlanModalContainer.tsx @@ -3,6 +3,8 @@ import * as Icons from 'lucide-react' import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard' +import type { NewPlanWizardState } from './types' + import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm' import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel' import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup' @@ -49,7 +51,6 @@ export default function NuevoPlanModalContainer() { const { wizard, setWizard, - carrerasFiltradas, canContinueDesdeModo, canContinueDesdeBasicos, canContinueDesdeDetalles, @@ -61,12 +62,20 @@ export default function NuevoPlanModalContainer() { } 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)) const nuevoId = (() => { - if (wizard.modoCreacion === 'MANUAL') return 'plan_new_manual_001' - if (wizard.modoCreacion === 'IA') return 'plan_new_ai_001' - if (wizard.subModoClonado === 'INTERNO') return 'plan_new_clone_001' + if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001' + if (wizard.tipoOrigen === 'IA') return 'plan_new_ai_001' + if ( + wizard.tipoOrigen === 'CLONADO_INTERNO' || + wizard.tipoOrigen === 'CLONADO_TRADICIONAL' + ) + return 'plan_new_clone_001' return 'plan_new_import_001' })() navigate({ to: `/planes/${nuevoId}` }) @@ -115,7 +124,10 @@ export default function NuevoPlanModalContainer() { {({ methods }) => { const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1 const totalSteps = Wizard.steps.length - const nextStep = Wizard.steps[currentIndex] + const nextStep = Wizard.steps[currentIndex] ?? { + title: '', + description: '', + } return ( <> @@ -124,7 +136,7 @@ export default function NuevoPlanModalContainer() { totalSteps={totalSteps} currentTitle={methods.current.title} currentDescription={methods.current.description} - nextTitle={nextStep?.title} + nextTitle={nextStep.title} onClose={handleClose} Wizard={Wizard} /> @@ -144,7 +156,6 @@ export default function NuevoPlanModalContainer() { )} diff --git a/src/features/planes/nuevo/catalogs.ts b/src/features/planes/nuevo/catalogs.ts index 805193e..afe98be 100644 --- a/src/features/planes/nuevo/catalogs.ts +++ b/src/features/planes/nuevo/catalogs.ts @@ -1,4 +1,4 @@ -import type { TipoCiclo } from "./types"; +import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain"; export const FACULTADES = [ { id: "ing", nombre: "Facultad de Ingeniería" }, @@ -16,16 +16,20 @@ export const CARRERAS = [ { id: "act", nombre: "Actuaría", facultadId: "neg" }, ]; -export const NIVELES = [ +export const NIVELES: Array = [ "Licenciatura", - "Especialidad", "Maestría", "Doctorado", + "Especialidad", + "Diplomado", + "Otro", ]; -export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [ - { value: "SEMESTRE", label: "Semestre" }, - { value: "CUATRIMESTRE", label: "Cuatrimestre" }, - { value: "TRIMESTRE", label: "Trimestre" }, + +export const TIPOS_CICLO: Array = [ + "Semestre", + "Cuatrimestre", + "Trimestre", + "Otro", ]; export const PLANES_EXISTENTES = [ diff --git a/src/features/planes/nuevo/hooks/useNuevoPlanWizard.ts b/src/features/planes/nuevo/hooks/useNuevoPlanWizard.ts index 8809d74..ff25daf 100644 --- a/src/features/planes/nuevo/hooks/useNuevoPlanWizard.ts +++ b/src/features/planes/nuevo/hooks/useNuevoPlanWizard.ts @@ -1,37 +1,33 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; -import { CARRERAS } from "../catalogs"; - -import type { NewPlanWizardState, PlanPreview, TipoCiclo } from "../types"; +import type { NewPlanWizardState, PlanPreview } from "../types"; +import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain"; export function useNuevoPlanWizard() { const [wizard, setWizard] = useState({ step: 1, - modoCreacion: null, - // datosBasicos: { - // nombrePlan: "", - // carreraId: "", - // facultadId: "", - // nivel: "", - // tipoCiclo: "", - // numCiclos: undefined, - // plantillaPlanId: "", - // plantillaPlanVersion: "", - // plantillaMapaId: "", - // plantillaMapaVersion: "", - // }, + tipoOrigen: null, 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", + nombrePlan: "", + carreraId: "", + facultadId: "", + nivel: "", + tipoCiclo: "", + numCiclos: undefined, + estructuraPlanId: null, }, + // 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 }, clonTradicional: { archivoWordPlanId: null, @@ -40,7 +36,6 @@ export function useNuevoPlanWizard() { }, iaConfig: { descripcionEnfoque: "", - poblacionObjetivo: "", notasAdicionales: "", archivosReferencia: [], repositoriosReferencia: [], @@ -51,14 +46,10 @@ export function useNuevoPlanWizard() { errorMessage: null, }); - const carrerasFiltradas = useMemo(() => { - const fac = wizard.datosBasicos.facultadId; - return fac ? CARRERAS.filter((c) => c.facultadId === fac) : CARRERAS; - }, [wizard.datosBasicos.facultadId]); - - const canContinueDesdeModo = wizard.modoCreacion === "MANUAL" || - wizard.modoCreacion === "IA" || - (wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado); + const canContinueDesdeModo = wizard.tipoOrigen === "MANUAL" || + wizard.tipoOrigen === "IA" || + (wizard.tipoOrigen === "CLONADO_INTERNO" || + wizard.tipoOrigen === "CLONADO_TRADICIONAL"); const canContinueDesdeBasicos = !!wizard.datosBasicos.nombrePlan && !!wizard.datosBasicos.carreraId && @@ -67,30 +58,25 @@ export function useNuevoPlanWizard() { (wizard.datosBasicos.numCiclos !== undefined && wizard.datosBasicos.numCiclos > 0) && // Requerir ambas plantillas (plan y mapa) con versión - !!wizard.datosBasicos.plantillaPlanId && - !!wizard.datosBasicos.plantillaPlanVersion && - !!wizard.datosBasicos.plantillaMapaId && - !!wizard.datosBasicos.plantillaMapaVersion; + !!wizard.datosBasicos.estructuraPlanId; const canContinueDesdeDetalles = (() => { - if (wizard.modoCreacion === "MANUAL") return true; - if (wizard.modoCreacion === "IA") { + if (wizard.tipoOrigen === "MANUAL") return true; + if (wizard.tipoOrigen === "IA") { // Requerimos descripción del enfoque y notas adicionales return !!wizard.iaConfig?.descripcionEnfoque && - !!wizard.iaConfig?.notasAdicionales; + !!wizard.iaConfig.notasAdicionales; } - if (wizard.modoCreacion === "CLONADO") { - if (wizard.subModoClonado === "INTERNO") { - return !!wizard.clonInterno?.planOrigenId; - } - if (wizard.subModoClonado === "TRADICIONAL") { - const t = wizard.clonTradicional; - if (!t) return false; - const tieneWord = !!t.archivoWordPlanId; - const tieneAlMenosUnExcel = !!t.archivoMapaExcelId || - !!t.archivoAsignaturasExcelId; - return tieneWord && tieneAlMenosUnExcel; - } + if (wizard.tipoOrigen === "CLONADO_INTERNO") { + return !!wizard.clonInterno?.planOrigenId; + } + if (wizard.tipoOrigen === "CLONADO_TRADICIONAL") { + const t = wizard.clonTradicional; + if (!t) return false; + const tieneWord = !!t.archivoWordPlanId; + const tieneAlMenosUnExcel = !!t.archivoMapaExcelId || + !!t.archivoAsignaturasExcelId; + return tieneWord && tieneAlMenosUnExcel; } return false; })(); @@ -101,7 +87,7 @@ export function useNuevoPlanWizard() { // Ensure preview has the stricter types required by `PlanPreview`. let tipoCicloSafe: TipoCiclo; if (wizard.datosBasicos.tipoCiclo === "") { - tipoCicloSafe = "SEMESTRE"; + tipoCicloSafe = "Semestre"; } else { tipoCicloSafe = wizard.datosBasicos.tipoCiclo; } @@ -112,7 +98,7 @@ export function useNuevoPlanWizard() { const preview: PlanPreview = { nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre", - nivel: wizard.datosBasicos.nivel || "Licenciatura", + nivel: wizard.datosBasicos.nivel as NivelPlanEstudio, tipoCiclo: tipoCicloSafe, numCiclos: numCiclosSafe, numAsignaturasAprox: numCiclosSafe * 6, @@ -121,7 +107,7 @@ export function useNuevoPlanWizard() { { id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" }, ], }; - setWizard((w) => ({ + setWizard((w: NewPlanWizardState) => ({ ...w, isLoading: false, resumen: { previewPlan: preview }, @@ -131,7 +117,6 @@ export function useNuevoPlanWizard() { return { wizard, setWizard, - carrerasFiltradas, canContinueDesdeModo, canContinueDesdeBasicos, canContinueDesdeDetalles, diff --git a/src/features/planes/nuevo/types.ts b/src/features/planes/nuevo/types.ts index 6ae3de0..61dab10 100644 --- a/src/features/planes/nuevo/types.ts +++ b/src/features/planes/nuevo/types.ts @@ -1,10 +1,13 @@ -export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE"; -export type ModoCreacion = "MANUAL" | "IA" | "CLONADO"; -export type SubModoClonado = "INTERNO" | "TRADICIONAL"; +import type { UploadedFile } from "@/components/planes/wizard/PasoDetallesPanel/FileDropZone"; +import type { + NivelPlanEstudio, + TipoCiclo, + TipoOrigen, +} from "@/data/types/domain"; export type PlanPreview = { nombrePlan: string; - nivel: string; + nivel: NivelPlanEstudio; tipoCiclo: TipoCiclo; numCiclos: number; numAsignaturasAprox?: number; @@ -13,20 +16,16 @@ export type PlanPreview = { export type NewPlanWizardState = { step: 1 | 2 | 3 | 4; - modoCreacion: ModoCreacion | null; - subModoClonado?: SubModoClonado; + tipoOrigen: TipoOrigen | null; datosBasicos: { nombrePlan: string; carreraId: string; facultadId: string; - nivel: string; + nivel: NivelPlanEstudio | ""; tipoCiclo: TipoCiclo | ""; numCiclos: number | undefined; // Selección de plantillas (obligatorias) - plantillaPlanId?: string; - plantillaPlanVersion?: string; - plantillaMapaId?: string; - plantillaMapaVersion?: string; + estructuraPlanId: string | null; }; clonInterno?: { planOrigenId: string | null }; clonTradicional?: { @@ -53,12 +52,11 @@ export type NewPlanWizardState = { }; iaConfig?: { descripcionEnfoque: string; - poblacionObjetivo: string; notasAdicionales: string; archivosReferencia: Array; repositoriosReferencia?: Array; archivosAdjuntos?: Array< - { id: string; name: string; size: string; type: string } + UploadedFile >; }; resumen: { previewPlan?: PlanPreview }; diff --git a/src/features/planes/utils/format-file-size.ts b/src/features/planes/utils/format-file-size.ts new file mode 100644 index 0000000..eaa1379 --- /dev/null +++ b/src/features/planes/utils/format-file-size.ts @@ -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"; +}; diff --git a/src/features/planes/utils/icon-utils.ts b/src/features/planes/utils/icon-utils.ts new file mode 100644 index 0000000..d65eb83 --- /dev/null +++ b/src/features/planes/utils/icon-utils.ts @@ -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; +}; diff --git a/src/main.tsx b/src/main.tsx index e43730c..d1fda21 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,10 +2,11 @@ import { RouterProvider, createRouter } from '@tanstack/react-router' import { StrictMode } from 'react' import ReactDOM from 'react-dom/client' -import * as TanStackQueryProvider from './integrations/tanstack-query/root-provider.tsx' import reportWebVitals from './reportWebVitals.ts' import { routeTree } from './routeTree.gen' +import * as TanStackQueryProvider from '@/data/query/queryClient.tsx' + import './styles.css' // Create a new router instance diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 2cbd6a8..3f8ce7e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/login' import { Route as DashboardRouteImport } from './routes/dashboard' 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 PlanesListaRouteRouteImport } from './routes/planes/_lista/route' import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo' @@ -44,6 +45,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const PlanesPlanesListRouteRoute = PlanesPlanesListRouteRouteImport.update({ + id: '/planes/PlanesListRoute', + path: '/planes/PlanesListRoute', + getParentRoute: () => rootRouteImport, +} as any) const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({ id: '/demo/tanstack-query', path: '/demo/tanstack-query', @@ -142,6 +148,7 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/planes': typeof PlanesListaRouteRouteWithChildren '/demo/tanstack-query': typeof DemoTanstackQueryRoute + '/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute '/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren '/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren '/planes/nuevo': typeof PlanesListaNuevoRoute @@ -162,6 +169,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/planes': typeof PlanesListaRouteRouteWithChildren '/demo/tanstack-query': typeof DemoTanstackQueryRoute + '/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute '/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren '/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute @@ -182,6 +190,7 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/planes/_lista': typeof PlanesListaRouteRouteWithChildren '/demo/tanstack-query': typeof DemoTanstackQueryRoute + '/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute '/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren '/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren '/planes/_lista/nuevo': typeof PlanesListaNuevoRoute @@ -205,6 +214,7 @@ export interface FileRouteTypes { | '/login' | '/planes' | '/demo/tanstack-query' + | '/planes/PlanesListRoute' | '/planes/$planId' | '/planes/$planId/asignaturas' | '/planes/nuevo' @@ -225,6 +235,7 @@ export interface FileRouteTypes { | '/login' | '/planes' | '/demo/tanstack-query' + | '/planes/PlanesListRoute' | '/planes/$planId' | '/planes/nuevo' | '/planes/$planId/asignaturas/$asignaturaId' @@ -244,6 +255,7 @@ export interface FileRouteTypes { | '/login' | '/planes/_lista' | '/demo/tanstack-query' + | '/planes/PlanesListRoute' | '/planes/$planId/_detalle' | '/planes/$planId/asignaturas' | '/planes/_lista/nuevo' @@ -266,6 +278,7 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute + PlanesPlanesListRouteRoute: typeof PlanesPlanesListRouteRoute PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren } @@ -293,6 +306,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/planes/PlanesListRoute': { + id: '/planes/PlanesListRoute' + path: '/planes/PlanesListRoute' + fullPath: '/planes/PlanesListRoute' + preLoaderRoute: typeof PlanesPlanesListRouteRouteImport + parentRoute: typeof rootRouteImport + } '/demo/tanstack-query': { id: '/demo/tanstack-query' path: '/demo/tanstack-query' @@ -486,6 +506,7 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren, DemoTanstackQueryRoute: DemoTanstackQueryRoute, + PlanesPlanesListRouteRoute: PlanesPlanesListRouteRoute, PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren, PlanesPlanIdAsignaturasRouteRoute: PlanesPlanIdAsignaturasRouteRouteWithChildren, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index bb05b48..df2ae01 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -30,4 +30,29 @@ export const Route = createRootRouteWithContext()({ /> ), + + errorComponent: ({ error, reset }) => { + return ( +
+

+ ¡Ups! Algo salió mal +

+

+ Ocurrió un error inesperado al cargar esta sección. +

+ + {/* Opcional: Mostrar el detalle técnico en desarrollo */} +
+          {error.message}
+        
+ + +
+ ) + }, }) diff --git a/src/routes/planes/PlanesListRoute.tsx b/src/routes/planes/PlanesListRoute.tsx new file mode 100644 index 0000000..ed63c2b --- /dev/null +++ b/src/routes/planes/PlanesListRoute.tsx @@ -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 ( +
+

Planes

+ + setSearch(e.target.value)} + placeholder="Buscar…" + /> + + {isLoading &&
Cargando…
} + {isError &&
Error: {(error as any).message}
} + +
    + {(data?.data ?? []).map((p) => ( +
  • +
    {JSON.stringify(p, null, 2)}
    +
  • + ))} +
+
+ ) +} diff --git a/src/routes/planes/_lista/route.tsx b/src/routes/planes/_lista/route.tsx index eddb20a..79ee09d 100644 --- a/src/routes/planes/_lista/route.tsx +++ b/src/routes/planes/_lista/route.tsx @@ -1,12 +1,15 @@ import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router' import * as Icons from 'lucide-react' -import { useMemo, useState } from 'react' - -import type { Option } from '@/components/planes/Filtro' +import { useState, useMemo } from 'react' +import { useDebounce } from 'use-debounce' +// Componentes import BarraBusqueda from '@/components/planes/BarraBusqueda' import Filtro from '@/components/planes/Filtro' 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')({ component: RouteComponent, @@ -14,215 +17,104 @@ export const Route = createFileRoute('/planes/_lista')({ function RouteComponent() { 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) - const facultades: Array = [ - { 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 = [ - { - 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