Merge remote-tracking branch 'origin/feat/wizard-plan-vista' into feature/IntegrarDetallePlan
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
19
scripts/update-types.ts
Normal file
19
scripts/update-types.ts
Normal 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);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function PasoConfiguracionPanel({
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="min-h-[100px]"
|
||||
className="min-h-25"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
@@ -213,7 +213,7 @@ export function PasoConfiguracionPanel({
|
||||
</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) => (
|
||||
<div
|
||||
key={m.id}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function StepWithTooltip({
|
||||
{title}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[200px] text-xs">
|
||||
<TooltipContent className="max-w-50 text-xs">
|
||||
<p>{desc}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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<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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
@@ -40,13 +48,18 @@ export function PasoBasicosForm({
|
||||
</Label>
|
||||
<Input
|
||||
id="nombrePlan"
|
||||
placeholder="Ej. Ingeniería en Sistemas 2026"
|
||||
placeholder="Ej. Ingeniería en Sistemas (2026)"
|
||||
value={wizard.datosBasicos.nombrePlan}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
|
||||
}))
|
||||
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({
|
||||
<Select
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
facultadId: value,
|
||||
carreraId: '',
|
||||
},
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -79,7 +94,7 @@ export function PasoBasicosForm({
|
||||
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACULTADES.map((f) => (
|
||||
{facultadesList.map((f: FacultadRow) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
@@ -93,10 +108,12 @@ export function PasoBasicosForm({
|
||||
<Select
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
disabled={!wizard.datosBasicos.facultadId}
|
||||
>
|
||||
@@ -112,7 +129,7 @@ export function PasoBasicosForm({
|
||||
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{carrerasFiltradas.map((c) => (
|
||||
{filteredCarreras.map((c: any) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.nombre}
|
||||
</SelectItem>
|
||||
@@ -125,11 +142,13 @@ export function PasoBasicosForm({
|
||||
<Label htmlFor="nivel">Nivel</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.nivel}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
onValueChange={(value: NivelPlanEstudio) =>
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -157,14 +176,16 @@ export function PasoBasicosForm({
|
||||
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.tipoCiclo}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
onValueChange={(value: TipoCiclo) =>
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipoCiclo: value as any,
|
||||
},
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -180,8 +201,8 @@ export function PasoBasicosForm({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_CICLO.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -196,22 +217,63 @@ export function PasoBasicosForm({
|
||||
min={1}
|
||||
value={wizard.datosBasicos.numCiclos ?? ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
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),
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
},
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
placeholder="Ej. 8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<Separator className="my-3" />
|
||||
</div>
|
||||
{/* <Separator className="my-3" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TemplateSelectorCard
|
||||
cardTitle="Plantilla de plan de estudios"
|
||||
@@ -247,7 +309,7 @@ export function PasoBasicosForm({
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => (
|
||||
{files.map((item) => (
|
||||
<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"
|
||||
>
|
||||
{getFileIcon(file.type)}
|
||||
{getFileIcon(item.file.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<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 className="text-muted-foreground text-xs">{file.size}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -43,7 +44,7 @@ export function PasoDetallesPanel({
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.modoCreacion === 'IA') {
|
||||
if (wizard.tipoOrigen === 'IA') {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -116,14 +117,16 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
})
|
||||
}
|
||||
onFilesChange={(files) =>
|
||||
onChange((w) => ({
|
||||
onFilesChange={(files: Array<UploadedFile>) =>
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
archivosAdjuntos: files,
|
||||
},
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -162,10 +165,7 @@ export function PasoDetallesPanel({
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO'
|
||||
) {
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
@@ -269,10 +269,7 @@ export function PasoDetallesPanel({
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL'
|
||||
) {
|
||||
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -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<string>
|
||||
onToggleArchivo?: (id: string, checked: boolean) => void
|
||||
onToggleRepositorio?: (id: string, checked: boolean) => void
|
||||
onFilesChange?: (
|
||||
files: Array<{ id: string; name: string; size: string; type: string }>,
|
||||
) => void
|
||||
onFilesChange?: (files: Array<UploadedFile>) => void
|
||||
}) => {
|
||||
const [busquedaArchivos, setBusquedaArchivos] = useState('')
|
||||
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
|
||||
|
||||
@@ -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<React.SetStateAction<NewPlanWizardState>>
|
||||
}) {
|
||||
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({
|
||||
<Card
|
||||
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
modoCreacion: 'MANUAL',
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
tipoOrigen: 'MANUAL',
|
||||
}),
|
||||
)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
modoCreacion: 'MANUAL',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
tipoOrigen: 'MANUAL',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
@@ -70,19 +68,21 @@ export function PasoModoCardGroup({
|
||||
<Card
|
||||
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
modoCreacion: 'IA',
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
tipoOrigen: 'IA',
|
||||
}),
|
||||
)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
modoCreacion: 'IA',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
tipoOrigen: 'IA',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
@@ -99,11 +99,13 @@ export function PasoModoCardGroup({
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() => 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({
|
||||
</CardTitle>
|
||||
<CardDescription>Desde un plan existente o archivos.</CardDescription>
|
||||
</CardHeader>
|
||||
{wizard.modoCreacion === 'CLONADO' && (
|
||||
{(wizard.tipoOrigen === 'OTRO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
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'
|
||||
} `}
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<div>
|
||||
@@ -68,49 +61,20 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
{wizard.datosBasicos.tipoCiclo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
Plantilla plan:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaPlan?.name ||
|
||||
wizard.datosBasicos.plantillaPlanId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaPlanVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Mapa curricular:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaMapa?.name ||
|
||||
wizard.datosBasicos.plantillaMapaId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaMapaVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">Modo: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.modoCreacion === 'MANUAL' && 'Manual'}
|
||||
{wizard.modoCreacion === 'IA' && 'Generado con IA'}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' &&
|
||||
{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'}
|
||||
</span>
|
||||
</div>
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' && (
|
||||
{wizard.tipoOrigen === 'CLONADO_INTERNO' && (
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
Plan origen:{' '}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Plan origen: </span>
|
||||
<span className="font-medium">
|
||||
{(() => {
|
||||
const p = PLANES_EXISTENTES.find(
|
||||
@@ -123,17 +87,13 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL' && (
|
||||
{wizard.tipoOrigen === 'CLONADO_TRADICIONAL' && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Documentos adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Word del plan:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoWordPlanId?.name ||
|
||||
'—'}
|
||||
<span className="text-foreground">Word del plan:</span>{' '}
|
||||
{wizard.clonTradicional?.archivoWordPlanId?.name || '—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
@@ -150,7 +110,7 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'IA' && (
|
||||
{wizard.tipoOrigen === 'IA' && (
|
||||
<div className="bg-muted/50 mt-2 rounded-md p-3">
|
||||
<div>
|
||||
<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">
|
||||
{adjuntos.map((f) => (
|
||||
<li key={f.id}>
|
||||
<span className="text-foreground">{f.name}</span>{' '}
|
||||
<span>· {f.size}</span>
|
||||
<span className="text-foreground">
|
||||
{f.file.name}
|
||||
</span>{' '}
|
||||
<span>· {formatFileSize(f.file.size)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function StepWithTooltip({
|
||||
{title}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[200px] text-xs">
|
||||
<TooltipContent className="max-w-50 text-xs">
|
||||
<p>{desc}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
import { invokeEdge } from '../supabase/invokeEdge'
|
||||
import { buildRange, throwIfError, requireData } from './_helpers'
|
||||
|
||||
import { buildRange, requireData, throwIfError } from './_helpers'
|
||||
|
||||
import type {
|
||||
Asignatura,
|
||||
CambioPlan,
|
||||
@@ -12,12 +14,14 @@ import type {
|
||||
TipoCiclo,
|
||||
UUID,
|
||||
} from '../types/domain'
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
|
||||
const EDGE = {
|
||||
plans_create_manual: 'plans_create_manual',
|
||||
ai_generate_plan: 'ai_generate_plan',
|
||||
plans_persist_from_ai: 'plans_persist_from_ai',
|
||||
plans_clone_from_existing: 'plans_clone_from_existing',
|
||||
|
||||
plans_import_from_files: 'plans_import_from_files',
|
||||
|
||||
plans_update_fields: 'plans_update_fields',
|
||||
@@ -39,40 +43,82 @@ export type PlanListFilters = {
|
||||
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(
|
||||
filters: PlanListFilters = {},
|
||||
): Promise<Paged<PlanEstudio>> {
|
||||
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<PlanEstudio>,
|
||||
count: count ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
.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<PlanEstudio> {
|
||||
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 { data, error } = await supabase
|
||||
.from('lineas_plan')
|
||||
@@ -109,7 +157,7 @@ export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> {
|
||||
|
||||
export async function plan_asignaturas_list(
|
||||
planId: UUID,
|
||||
): Promise<Asignatura[]> {
|
||||
): Promise<Array<Asignatura>> {
|
||||
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<CambioPlan[]> {
|
||||
export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
|
||||
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<UUID>
|
||||
repositoriosIds?: Array<UUID>
|
||||
archivosAdjuntos: Array<UploadedFile>
|
||||
usarMCP?: boolean
|
||||
}
|
||||
}
|
||||
@@ -246,12 +295,12 @@ export type PlanMapOperation =
|
||||
op: 'REORDER_CELDA'
|
||||
linea_plan_id: UUID
|
||||
numero_ciclo: number
|
||||
asignaturaIdsOrdenados: UUID[]
|
||||
asignaturaIdsOrdenados: Array<UUID>
|
||||
}
|
||||
|
||||
export async function plans_update_map(
|
||||
planId: UUID,
|
||||
ops: PlanMapOperation[],
|
||||
ops: Array<PlanMapOperation>,
|
||||
): 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<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 ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
ai_plan_chat,
|
||||
ai_plan_improve,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<Database> | null = null;
|
||||
|
||||
export function supabaseBrowser(): SupabaseClient<Database> {
|
||||
@@ -10,13 +13,13 @@ export function supabaseBrowser(): SupabaseClient<Database> {
|
||||
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<Database>(url, anonKey, {
|
||||
|
||||
@@ -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)
|
||||
@@ -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<T> = { data: T[]; count: number | 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 PlanEstudioWithRel =
|
||||
& Tables<"planes_estudio">
|
||||
& {
|
||||
carreras:
|
||||
| Tables<"carreras"> & {
|
||||
facultades: Tables<"facultades"> | null;
|
||||
}
|
||||
| null;
|
||||
estados_plan: Tables<"estados_plan"> | 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 Paged<T> = { data: Array<T>; count: number | null };
|
||||
|
||||
facultades?: Facultad | 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 EstructuraPlan = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
tipo: TipoEstructuraPlan;
|
||||
version: string | null;
|
||||
definicion: Json;
|
||||
};
|
||||
export type LineaPlan = Tables<"lineas_plan">;
|
||||
|
||||
export type EstructuraAsignatura = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
version: string | null;
|
||||
definicion: Json;
|
||||
};
|
||||
export type Asignatura = Tables<"asignaturas">;
|
||||
|
||||
export type EstadoPlan = {
|
||||
id: UUID;
|
||||
clave: string;
|
||||
etiqueta: string;
|
||||
orden: number;
|
||||
es_final: boolean;
|
||||
};
|
||||
export type BibliografiaAsignatura = Tables<"bibliografia_asignatura">;
|
||||
|
||||
export type PlanEstudio = {
|
||||
id: UUID;
|
||||
carrera_id: UUID;
|
||||
estructura_id: UUID;
|
||||
export type CambioPlan = Tables<"cambios_plan">;
|
||||
|
||||
nombre: string;
|
||||
nivel: NivelPlanEstudio;
|
||||
tipo_ciclo: TipoCiclo;
|
||||
numero_ciclos: number;
|
||||
export type CambioAsignatura = Tables<"cambios_asignatura">;
|
||||
|
||||
datos: Json;
|
||||
export type InteraccionIA = Tables<"interacciones_ia">;
|
||||
|
||||
estado_actual_id: UUID | null;
|
||||
activo: boolean;
|
||||
export type TareaRevision = Tables<"tareas_revision">;
|
||||
|
||||
tipo_origen: TipoOrigen | null;
|
||||
meta_origen: Json;
|
||||
export type Notificacion = Tables<"notificaciones">;
|
||||
|
||||
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">;
|
||||
|
||||
@@ -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() {
|
||||
<PasoBasicosForm
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
carrerasFiltradas={carrerasFiltradas}
|
||||
/>
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
|
||||
@@ -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<NivelPlanEstudio> = [
|
||||
"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<TipoCiclo> = [
|
||||
"Semestre",
|
||||
"Cuatrimestre",
|
||||
"Trimestre",
|
||||
"Otro",
|
||||
];
|
||||
|
||||
export const PLANES_EXISTENTES = [
|
||||
|
||||
@@ -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<NewPlanWizardState>({
|
||||
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,23 +58,19 @@ 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") {
|
||||
if (wizard.tipoOrigen === "CLONADO_INTERNO") {
|
||||
return !!wizard.clonInterno?.planOrigenId;
|
||||
}
|
||||
if (wizard.subModoClonado === "TRADICIONAL") {
|
||||
if (wizard.tipoOrigen === "CLONADO_TRADICIONAL") {
|
||||
const t = wizard.clonTradicional;
|
||||
if (!t) return false;
|
||||
const tieneWord = !!t.archivoWordPlanId;
|
||||
@@ -91,7 +78,6 @@ export function useNuevoPlanWizard() {
|
||||
!!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,
|
||||
|
||||
@@ -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<string>;
|
||||
repositoriosReferencia?: Array<string>;
|
||||
archivosAdjuntos?: Array<
|
||||
{ id: string; name: string; size: string; type: string }
|
||||
UploadedFile
|
||||
>;
|
||||
};
|
||||
resumen: { previewPlan?: PlanPreview };
|
||||
|
||||
5
src/features/planes/utils/format-file-size.ts
Normal file
5
src/features/planes/utils/format-file-size.ts
Normal 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";
|
||||
};
|
||||
10
src/features/planes/utils/icon-utils.ts
Normal file
10
src/features/planes/utils/icon-utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
42
src/routes/planes/PlanesListRoute.tsx
Normal file
42
src/routes/planes/PlanesListRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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<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
|
||||
// 1. Estados de Filtros
|
||||
const [search, setSearch] = useState('')
|
||||
// Debounce para evitar llamadas excesivas a la API
|
||||
const [debouncedSearch] = useDebounce(search, 500)
|
||||
|
||||
const [facultadSel, setFacultadSel] = useState<string>('todas')
|
||||
const [carreraSel, setCarreraSel] = useState<string>('todas')
|
||||
const [estadoSel, setEstadoSel] = useState<string>('todos')
|
||||
|
||||
// Opciones para filtros
|
||||
const facultadesOptions: Array<Option> = useMemo(
|
||||
// Paginación (opcional si la implementas en UI)
|
||||
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' },
|
||||
...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 list =
|
||||
const carrerasOptions = useMemo(() => {
|
||||
// Filtramos las carreras del catálogo base según la facultad seleccionada
|
||||
const rawCarreras = catalogos?.carreras ?? []
|
||||
const filtered =
|
||||
facultadSel === 'todas'
|
||||
? carreras
|
||||
: carreras.filter((c) => c.facultadId === facultadSel)
|
||||
? rawCarreras
|
||||
: rawCarreras.filter((c) => c.facultad_id === facultadSel)
|
||||
|
||||
return [
|
||||
{ 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 filteredPlans = useMemo(() => {
|
||||
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD') // Descompone "á" en "a" + "´"
|
||||
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
||||
.toLowerCase() // Convierte a minúsculas
|
||||
}
|
||||
// Limpiamos el término de búsqueda una sola vez antes de filtrar
|
||||
const term = cleanText(search.trim())
|
||||
return planes.filter((p) => {
|
||||
const matchName = term
|
||||
? // 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])
|
||||
const estadosOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'todos', label: 'Todos los estados' },
|
||||
...(catalogos?.estados.map((e) => ({ value: e.id, label: e.etiqueta })) ??
|
||||
[]),
|
||||
],
|
||||
[catalogos?.estados],
|
||||
)
|
||||
|
||||
// 4. Handlers
|
||||
const resetFilters = () => {
|
||||
setSearch('')
|
||||
setFacultadSel('todas')
|
||||
setCarreraSel('todas')
|
||||
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 (
|
||||
<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="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 items-center gap-3">
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="font-display text-foreground text-2xl font-bold">
|
||||
Planes de Estudio
|
||||
@@ -232,31 +124,23 @@ function RouteComponent() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'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' })
|
||||
}}
|
||||
onClick={() => navigate({ to: '/planes/nuevo' })}
|
||||
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"
|
||||
>
|
||||
<Icons.Plus className="" />
|
||||
Nuevo plan de estudios
|
||||
<Icons.Plus /> Nuevo plan de estudios
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Barra de Filtros */}
|
||||
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<BarraBusqueda
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Buscar por programa…"
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Buscar por programa..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch justify-between gap-2 lg:flex-row lg:items-center">
|
||||
<div className="w-full lg:w-44">
|
||||
<Filtro
|
||||
@@ -264,67 +148,93 @@ function RouteComponent() {
|
||||
value={facultadSel}
|
||||
onChange={(v) => {
|
||||
setFacultadSel(v)
|
||||
// Reset carrera si ya no pertenece
|
||||
setCarreraSel('todas')
|
||||
setPage(0)
|
||||
}}
|
||||
placeholder="Facultad"
|
||||
ariaLabel="Filtro por facultad"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-44">
|
||||
<Filtro
|
||||
options={carrerasOptions}
|
||||
value={carreraSel}
|
||||
onChange={setCarreraSel}
|
||||
onChange={(v) => {
|
||||
setCarreraSel(v)
|
||||
setPage(0)
|
||||
}}
|
||||
placeholder="Carrera"
|
||||
ariaLabel="Filtro por carrera"
|
||||
disabled={facultadSel === 'todas'}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-44">
|
||||
<Filtro
|
||||
options={estados}
|
||||
options={estadosOptions}
|
||||
value={estadoSel}
|
||||
onChange={setEstadoSel}
|
||||
onChange={(v) => {
|
||||
setEstadoSel(v)
|
||||
setPage(0)
|
||||
}}
|
||||
placeholder="Estado"
|
||||
ariaLabel="Filtro por estado"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
className={
|
||||
'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"
|
||||
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"
|
||||
>
|
||||
<Icons.X className="h-4 w-4" />
|
||||
Limpiar
|
||||
<Icons.X className="h-4 w-4" /> Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de Resultados */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredPlans.map((p) => {
|
||||
const fac = facultades.find((f) => f.id === p.facultadId)!
|
||||
const IconComp = (Icons as any)[p.icon] ?? Icons.BookOpenText
|
||||
{/* Skeleton básico o Spinner */}
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-64 w-full animate-pulse rounded-xl bg-gray-100/50"
|
||||
/>
|
||||
))}
|
||||
</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={p.id}
|
||||
Icono={IconComp}
|
||||
nombrePrograma={p.nombrePrograma}
|
||||
nivel={p.nivel}
|
||||
ciclos={p.ciclos}
|
||||
facultad={fac.nombre}
|
||||
estado={p.estado}
|
||||
claseColorEstado={p.claseColorEstado}
|
||||
colorFacultad={fac.color}
|
||||
onClick={() => console.log('Ver', p.nombrePrograma)}
|
||||
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>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
1293
src/types/supabase.ts
Normal file
1293
src/types/supabase.ts
Normal file
File diff suppressed because it is too large
Load Diff
1
supabase/.temp/cli-latest
Normal file
1
supabase/.temp/cli-latest
Normal file
@@ -0,0 +1 @@
|
||||
v2.67.1
|
||||
1
supabase/.temp/gotrue-version
Normal file
1
supabase/.temp/gotrue-version
Normal file
@@ -0,0 +1 @@
|
||||
v2.184.1
|
||||
1
supabase/.temp/pooler-url
Normal file
1
supabase/.temp/pooler-url
Normal file
@@ -0,0 +1 @@
|
||||
postgresql://postgres.exdkssurzmjnnhgtiama@aws-0-us-west-1.pooler.supabase.com:5432/postgres
|
||||
1
supabase/.temp/postgres-version
Normal file
1
supabase/.temp/postgres-version
Normal file
@@ -0,0 +1 @@
|
||||
15.8.1.085
|
||||
1
supabase/.temp/project-ref
Normal file
1
supabase/.temp/project-ref
Normal file
@@ -0,0 +1 @@
|
||||
exdkssurzmjnnhgtiama
|
||||
1
supabase/.temp/rest-version
Normal file
1
supabase/.temp/rest-version
Normal file
@@ -0,0 +1 @@
|
||||
v12.2.3
|
||||
1
supabase/.temp/storage-migration
Normal file
1
supabase/.temp/storage-migration
Normal file
@@ -0,0 +1 @@
|
||||
buckets-objects-grants-postgres
|
||||
1
supabase/.temp/storage-version
Normal file
1
supabase/.temp/storage-version
Normal file
@@ -0,0 +1 @@
|
||||
v1.33.0
|
||||
Reference in New Issue
Block a user