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
|
FROM oven/bun:1 AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install
|
||||||
RUN bunx --bun vite build
|
RUN bunx --bun vite build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// 5. PRETTIER AL FINAL
|
// 5. OVERRIDE: desactivar reglas para tipos generados por supabase
|
||||||
|
{
|
||||||
|
files: ['src/types/supabase.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/naming-convention': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 6. PRETTIER AL FINAL
|
||||||
eslintConfigPrettier,
|
eslintConfigPrettier,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@@ -50,13 +50,15 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.0.6",
|
"tailwindcss": "^4.0.6",
|
||||||
"tw-animate-css": "^1.3.6"
|
"tw-animate-css": "^1.3.6",
|
||||||
|
"use-debounce": "^10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/devtools-vite": "^0.3.11",
|
"@tanstack/devtools-vite": "^0.3.11",
|
||||||
"@tanstack/eslint-config": "^0.3.0",
|
"@tanstack/eslint-config": "^0.3.0",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@types/bun": "^1.3.6",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
@@ -70,6 +72,7 @@
|
|||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"supabase": "^2.72.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
"vitest": "^3.0.5",
|
"vitest": "^3.0.5",
|
||||||
|
|||||||
19
scripts/update-types.ts
Normal file
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>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
@@ -213,7 +213,7 @@ export function PasoConfiguracionPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid max-h-[300px] gap-2 overflow-y-auto">
|
<div className="grid max-h-75 gap-2 overflow-y-auto">
|
||||||
{MATERIAS_MOCK.map((m) => (
|
{MATERIAS_MOCK.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function StepWithTooltip({
|
|||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-[200px] text-xs">
|
<TooltipContent className="max-w-50 text-xs">
|
||||||
<p>{desc}</p>
|
<p>{desc}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { TemplateSelectorCard } from './TemplateSelectorCard'
|
import type {
|
||||||
|
EstructuraPlanRow,
|
||||||
import type { CARRERAS } from '@/features/planes/nuevo/catalogs'
|
FacultadRow,
|
||||||
|
NivelPlanEstudio,
|
||||||
|
TipoCiclo,
|
||||||
|
} from '@/data/types/domain'
|
||||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -12,25 +15,30 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { useCatalogosPlanes } from '@/data/hooks/usePlans'
|
||||||
import {
|
import { NIVELES, TIPOS_CICLO } from '@/features/planes/nuevo/catalogs'
|
||||||
FACULTADES,
|
|
||||||
NIVELES,
|
|
||||||
TIPOS_CICLO,
|
|
||||||
PLANTILLAS_ANEXO_1,
|
|
||||||
PLANTILLAS_ANEXO_2,
|
|
||||||
} from '@/features/planes/nuevo/catalogs'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function PasoBasicosForm({
|
export function PasoBasicosForm({
|
||||||
wizard,
|
wizard,
|
||||||
onChange,
|
onChange,
|
||||||
carrerasFiltradas,
|
|
||||||
}: {
|
}: {
|
||||||
wizard: NewPlanWizardState
|
wizard: NewPlanWizardState
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||||
carrerasFiltradas: typeof CARRERAS
|
|
||||||
}) {
|
}) {
|
||||||
|
const { data: catalogos } = useCatalogosPlanes()
|
||||||
|
|
||||||
|
// Preferir los catálogos remotos si están disponibles; si no, usar los locales
|
||||||
|
const facultadesList = catalogos?.facultades ?? []
|
||||||
|
const rawCarreras = catalogos?.carreras ?? []
|
||||||
|
const estructurasPlanList = catalogos?.estructurasPlan ?? []
|
||||||
|
|
||||||
|
const filteredCarreras = rawCarreras.filter((c: any) => {
|
||||||
|
const facId = wizard.datosBasicos.facultadId
|
||||||
|
if (!facId) return true
|
||||||
|
// soportar ambos shapes: `facultad_id` (BD) o `facultadId` (local)
|
||||||
|
return c.facultad_id ? c.facultad_id === facId : c.facultadId === facId
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
@@ -40,13 +48,18 @@ export function PasoBasicosForm({
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="nombrePlan"
|
id="nombrePlan"
|
||||||
placeholder="Ej. Ingeniería en Sistemas 2026"
|
placeholder="Ej. Ingeniería en Sistemas (2026)"
|
||||||
value={wizard.datosBasicos.nombrePlan}
|
value={wizard.datosBasicos.nombrePlan}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
|
...w,
|
||||||
}))
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
nombrePlan: e.target.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||||
/>
|
/>
|
||||||
@@ -57,14 +70,16 @@ export function PasoBasicosForm({
|
|||||||
<Select
|
<Select
|
||||||
value={wizard.datosBasicos.facultadId}
|
value={wizard.datosBasicos.facultadId}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
datosBasicos: {
|
...w,
|
||||||
...w.datosBasicos,
|
datosBasicos: {
|
||||||
facultadId: value,
|
...w.datosBasicos,
|
||||||
carreraId: '',
|
facultadId: value,
|
||||||
},
|
carreraId: '',
|
||||||
}))
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
@@ -79,7 +94,7 @@ export function PasoBasicosForm({
|
|||||||
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
|
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FACULTADES.map((f) => (
|
{facultadesList.map((f: FacultadRow) => (
|
||||||
<SelectItem key={f.id} value={f.id}>
|
<SelectItem key={f.id} value={f.id}>
|
||||||
{f.nombre}
|
{f.nombre}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -93,10 +108,12 @@ export function PasoBasicosForm({
|
|||||||
<Select
|
<Select
|
||||||
value={wizard.datosBasicos.carreraId}
|
value={wizard.datosBasicos.carreraId}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
...w,
|
||||||
}))
|
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
disabled={!wizard.datosBasicos.facultadId}
|
disabled={!wizard.datosBasicos.facultadId}
|
||||||
>
|
>
|
||||||
@@ -112,7 +129,7 @@ export function PasoBasicosForm({
|
|||||||
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
|
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{carrerasFiltradas.map((c) => (
|
{filteredCarreras.map((c: any) => (
|
||||||
<SelectItem key={c.id} value={c.id}>
|
<SelectItem key={c.id} value={c.id}>
|
||||||
{c.nombre}
|
{c.nombre}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -125,11 +142,13 @@ export function PasoBasicosForm({
|
|||||||
<Label htmlFor="nivel">Nivel</Label>
|
<Label htmlFor="nivel">Nivel</Label>
|
||||||
<Select
|
<Select
|
||||||
value={wizard.datosBasicos.nivel}
|
value={wizard.datosBasicos.nivel}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value: NivelPlanEstudio) =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
datosBasicos: { ...w.datosBasicos, nivel: value },
|
...w,
|
||||||
}))
|
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
@@ -157,14 +176,16 @@ export function PasoBasicosForm({
|
|||||||
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
||||||
<Select
|
<Select
|
||||||
value={wizard.datosBasicos.tipoCiclo}
|
value={wizard.datosBasicos.tipoCiclo}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value: TipoCiclo) =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
datosBasicos: {
|
...w,
|
||||||
...w.datosBasicos,
|
datosBasicos: {
|
||||||
tipoCiclo: value as any,
|
...w.datosBasicos,
|
||||||
},
|
tipoCiclo: value as any,
|
||||||
}))
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
@@ -180,8 +201,8 @@ export function PasoBasicosForm({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIPOS_CICLO.map((t) => (
|
{TIPOS_CICLO.map((t) => (
|
||||||
<SelectItem key={t.value} value={t.value}>
|
<SelectItem key={t} value={t}>
|
||||||
{t.label}
|
{t}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -196,22 +217,63 @@ export function PasoBasicosForm({
|
|||||||
min={1}
|
min={1}
|
||||||
value={wizard.datosBasicos.numCiclos ?? ''}
|
value={wizard.datosBasicos.numCiclos ?? ''}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
datosBasicos: {
|
...w,
|
||||||
...w.datosBasicos,
|
datosBasicos: {
|
||||||
// Keep undefined when the input is empty so the field stays optional
|
...w.datosBasicos,
|
||||||
numCiclos:
|
// Keep undefined when the input is empty so the field stays optional
|
||||||
e.target.value === '' ? undefined : Number(e.target.value),
|
numCiclos:
|
||||||
},
|
e.target.value === ''
|
||||||
}))
|
? undefined
|
||||||
|
: Number(e.target.value),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||||
placeholder="Ej. 8"
|
placeholder="Ej. 8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="estructuraPlan">Estructura de plan de estudios</Label>
|
||||||
|
<Select
|
||||||
|
value={wizard.datosBasicos.estructuraPlanId ?? ''}
|
||||||
|
onValueChange={(value: string) =>
|
||||||
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
estructuraPlanId: value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="tipoCiclo"
|
||||||
|
className={cn(
|
||||||
|
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||||
|
!wizard.datosBasicos.estructuraPlanId
|
||||||
|
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||||
|
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Ej. Plan base SEP/ULSA (2026)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{estructurasPlanList.map((t: EstructuraPlanRow) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-3" />
|
{/* <Separator className="my-3" />
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<TemplateSelectorCard
|
<TemplateSelectorCard
|
||||||
cardTitle="Plantilla de plan de estudios"
|
cardTitle="Plantilla de plan de estudios"
|
||||||
@@ -247,7 +309,7 @@ export function PasoBasicosForm({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { Upload, File, X, FileText } from 'lucide-react'
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { formatFileSize } from '@/features/planes/utils/format-file-size'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface UploadedFile {
|
export interface UploadedFile {
|
||||||
id: string
|
id: string // Necesario para React (key)
|
||||||
name: string
|
file: File // La fuente de verdad (contiene name, size, type)
|
||||||
size: string
|
preview?: string // Opcional: si fueran imágenes
|
||||||
type: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileDropzoneProps {
|
interface FileDropzoneProps {
|
||||||
@@ -37,9 +37,7 @@ export function FileDropzone({
|
|||||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
? (crypto as any).randomUUID()
|
? (crypto as any).randomUUID()
|
||||||
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
name: file.name,
|
file,
|
||||||
size: formatFileSize(file.size),
|
|
||||||
type: file.name.split('.').pop() || 'file',
|
|
||||||
}))
|
}))
|
||||||
setFiles((prev) => {
|
setFiles((prev) => {
|
||||||
const room = Math.max(0, maxFiles - prev.length)
|
const room = Math.max(0, maxFiles - prev.length)
|
||||||
@@ -97,12 +95,6 @@ export function FileDropzone({
|
|||||||
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
|
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
|
||||||
}, [files])
|
}, [files])
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes < 1024) return bytes + ' B'
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFileIcon = (type: string) => {
|
const getFileIcon = (type: string) => {
|
||||||
switch (type.toLowerCase()) {
|
switch (type.toLowerCase()) {
|
||||||
case 'pdf':
|
case 'pdf':
|
||||||
@@ -170,23 +162,25 @@ export function FileDropzone({
|
|||||||
{/* Uploaded files list */}
|
{/* Uploaded files list */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{files.map((file) => (
|
{files.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={item.id}
|
||||||
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
|
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
{getFileIcon(file.type)}
|
{getFileIcon(item.file.type)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-foreground truncate text-sm font-medium">
|
<p className="text-foreground truncate text-sm font-medium">
|
||||||
{file.name}
|
{item.file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatFileSize(item.file.size)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">{file.size}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||||
onClick={() => removeFile(file.id)}
|
onClick={() => removeFile(item.id)}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FileDropzone } from './FileDropZone'
|
import { FileDropzone } from './FileDropZone'
|
||||||
import ReferenciasParaIA from './ReferenciasParaIA'
|
import ReferenciasParaIA from './ReferenciasParaIA'
|
||||||
|
|
||||||
|
import type { UploadedFile } from './FileDropZone'
|
||||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -30,7 +31,7 @@ export function PasoDetallesPanel({
|
|||||||
onGenerarIA: () => void
|
onGenerarIA: () => void
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}) {
|
}) {
|
||||||
if (wizard.modoCreacion === 'MANUAL') {
|
if (wizard.tipoOrigen === 'MANUAL') {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -43,7 +44,7 @@ export function PasoDetallesPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wizard.modoCreacion === 'IA') {
|
if (wizard.tipoOrigen === 'IA') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -116,14 +117,16 @@ export function PasoDetallesPanel({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onFilesChange={(files) =>
|
onFilesChange={(files: Array<UploadedFile>) =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
iaConfig: {
|
...w,
|
||||||
...(w.iaConfig || ({} as any)),
|
iaConfig: {
|
||||||
archivosAdjuntos: files,
|
...(w.iaConfig || ({} as any)),
|
||||||
},
|
archivosAdjuntos: files,
|
||||||
}))
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -162,10 +165,7 @@ export function PasoDetallesPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||||
wizard.modoCreacion === 'CLONADO' &&
|
|
||||||
wizard.subModoClonado === 'INTERNO'
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
@@ -269,10 +269,7 @@ export function PasoDetallesPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
||||||
wizard.modoCreacion === 'CLONADO' &&
|
|
||||||
wizard.subModoClonado === 'TRADICIONAL'
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import BarraBusqueda from '../../BarraBusqueda'
|
|||||||
|
|
||||||
import { FileDropzone } from './FileDropZone'
|
import { FileDropzone } from './FileDropZone'
|
||||||
|
|
||||||
|
import type { UploadedFile } from './FileDropZone'
|
||||||
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
@@ -27,9 +29,7 @@ const ReferenciasParaIA = ({
|
|||||||
selectedRepositorioIds?: Array<string>
|
selectedRepositorioIds?: Array<string>
|
||||||
onToggleArchivo?: (id: string, checked: boolean) => void
|
onToggleArchivo?: (id: string, checked: boolean) => void
|
||||||
onToggleRepositorio?: (id: string, checked: boolean) => void
|
onToggleRepositorio?: (id: string, checked: boolean) => void
|
||||||
onFilesChange?: (
|
onFilesChange?: (files: Array<UploadedFile>) => void
|
||||||
files: Array<{ id: string; name: string; size: string; type: string }>,
|
|
||||||
) => void
|
|
||||||
}) => {
|
}) => {
|
||||||
const [busquedaArchivos, setBusquedaArchivos] = useState('')
|
const [busquedaArchivos, setBusquedaArchivos] = useState('')
|
||||||
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
|
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import * as Icons from 'lucide-react'
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
import type {
|
import type { TipoOrigen } from '@/data/types/domain'
|
||||||
NewPlanWizardState,
|
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||||
ModoCreacion,
|
|
||||||
SubModoClonado,
|
|
||||||
} from '@/features/planes/nuevo/types'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -21,8 +18,7 @@ export function PasoModoCardGroup({
|
|||||||
wizard: NewPlanWizardState
|
wizard: NewPlanWizardState
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||||
}) {
|
}) {
|
||||||
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
|
const isSelected = (m: TipoOrigen) => wizard.tipoOrigen === m
|
||||||
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
|
|
||||||
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
|
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
|
||||||
const key = e.key
|
const key = e.key
|
||||||
if (
|
if (
|
||||||
@@ -41,19 +37,21 @@ export function PasoModoCardGroup({
|
|||||||
<Card
|
<Card
|
||||||
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
|
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
modoCreacion: 'MANUAL',
|
...w,
|
||||||
subModoClonado: undefined,
|
tipoOrigen: 'MANUAL',
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onKeyDown={(e: React.KeyboardEvent) =>
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
handleKeyActivate(e, () =>
|
handleKeyActivate(e, () =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
modoCreacion: 'MANUAL',
|
...w,
|
||||||
subModoClonado: undefined,
|
tipoOrigen: 'MANUAL',
|
||||||
})),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -70,19 +68,21 @@ export function PasoModoCardGroup({
|
|||||||
<Card
|
<Card
|
||||||
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
modoCreacion: 'IA',
|
...w,
|
||||||
subModoClonado: undefined,
|
tipoOrigen: 'IA',
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onKeyDown={(e: React.KeyboardEvent) =>
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
handleKeyActivate(e, () =>
|
handleKeyActivate(e, () =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
...w,
|
(w): NewPlanWizardState => ({
|
||||||
modoCreacion: 'IA',
|
...w,
|
||||||
subModoClonado: undefined,
|
tipoOrigen: 'IA',
|
||||||
})),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -99,11 +99,13 @@ export function PasoModoCardGroup({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
|
className={isSelected('OTRO') ? 'ring-ring ring-2' : ''}
|
||||||
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
|
onClick={() =>
|
||||||
|
onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' }))
|
||||||
|
}
|
||||||
onKeyDown={(e: React.KeyboardEvent) =>
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
handleKeyActivate(e, () =>
|
handleKeyActivate(e, () =>
|
||||||
onChange((w) => ({ ...w, modoCreacion: 'CLONADO' })),
|
onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' })),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -115,22 +117,34 @@ export function PasoModoCardGroup({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Desde un plan existente o archivos.</CardDescription>
|
<CardDescription>Desde un plan existente o archivos.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{wizard.modoCreacion === 'CLONADO' && (
|
{(wizard.tipoOrigen === 'OTRO' ||
|
||||||
|
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||||
|
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'CLONADO_INTERNO',
|
||||||
|
}),
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e: React.KeyboardEvent) =>
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
handleKeyActivate(e, () =>
|
handleKeyActivate(e, () =>
|
||||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })),
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'CLONADO_INTERNO',
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||||
isSubSelected('INTERNO')
|
isSelected('CLONADO_INTERNO')
|
||||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||||
: 'border-border text-muted-foreground'
|
: 'border-border text-muted-foreground'
|
||||||
} `}
|
} `}
|
||||||
@@ -144,15 +158,25 @@ export function PasoModoCardGroup({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'CLONADO_TRADICIONAL',
|
||||||
|
}),
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e: React.KeyboardEvent) =>
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
handleKeyActivate(e, () =>
|
handleKeyActivate(e, () =>
|
||||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })),
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'CLONADO_TRADICIONAL',
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||||
isSubSelected('TRADICIONAL')
|
isSelected('CLONADO_TRADICIONAL')
|
||||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||||
: 'border-border text-muted-foreground'
|
: 'border-border text-muted-foreground'
|
||||||
} `}
|
} `}
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
PLANTILLAS_ANEXO_1,
|
|
||||||
PLANTILLAS_ANEXO_2,
|
|
||||||
PLANES_EXISTENTES,
|
PLANES_EXISTENTES,
|
||||||
ARCHIVOS,
|
ARCHIVOS,
|
||||||
REPOSITORIOS,
|
REPOSITORIOS,
|
||||||
} from '@/features/planes/nuevo/catalogs'
|
} from '@/features/planes/nuevo/catalogs'
|
||||||
|
import { formatFileSize } from '@/features/planes/utils/format-file-size'
|
||||||
|
|
||||||
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||||
return (
|
return (
|
||||||
@@ -32,12 +31,6 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
|||||||
const repositoriosRef =
|
const repositoriosRef =
|
||||||
wizard.iaConfig?.repositoriosReferencia ?? []
|
wizard.iaConfig?.repositoriosReferencia ?? []
|
||||||
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
|
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
|
||||||
const plantillaPlan = PLANTILLAS_ANEXO_1.find(
|
|
||||||
(x) => x.id === wizard.datosBasicos.plantillaPlanId,
|
|
||||||
)
|
|
||||||
const plantillaMapa = PLANTILLAS_ANEXO_2.find(
|
|
||||||
(x) => x.id === wizard.datosBasicos.plantillaMapaId,
|
|
||||||
)
|
|
||||||
const contenido = (
|
const contenido = (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
@@ -68,89 +61,56 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
|||||||
{wizard.datosBasicos.tipoCiclo})
|
{wizard.datosBasicos.tipoCiclo})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Plantilla plan:{' '}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{(plantillaPlan?.name ||
|
|
||||||
wizard.datosBasicos.plantillaPlanId ||
|
|
||||||
'—') +
|
|
||||||
' · ' +
|
|
||||||
(wizard.datosBasicos.plantillaPlanVersion || '—')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Mapa curricular:{' '}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{(plantillaMapa?.name ||
|
|
||||||
wizard.datosBasicos.plantillaMapaId ||
|
|
||||||
'—') +
|
|
||||||
' · ' +
|
|
||||||
(wizard.datosBasicos.plantillaMapaVersion || '—')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<span className="text-muted-foreground">Modo: </span>
|
<span className="text-muted-foreground">Modo: </span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{wizard.modoCreacion === 'MANUAL' && 'Manual'}
|
{wizard.tipoOrigen === 'MANUAL' && 'Manual'}
|
||||||
{wizard.modoCreacion === 'IA' && 'Generado con IA'}
|
{wizard.tipoOrigen === 'IA' && 'Generado con IA'}
|
||||||
{wizard.modoCreacion === 'CLONADO' &&
|
{wizard.tipoOrigen === 'CLONADO_INTERNO' &&
|
||||||
wizard.subModoClonado === 'INTERNO' &&
|
|
||||||
'Clonado desde plan del sistema'}
|
'Clonado desde plan del sistema'}
|
||||||
{wizard.modoCreacion === 'CLONADO' &&
|
{wizard.tipoOrigen === 'CLONADO_TRADICIONAL' &&
|
||||||
wizard.subModoClonado === 'TRADICIONAL' &&
|
|
||||||
'Importado desde documentos tradicionales'}
|
'Importado desde documentos tradicionales'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{wizard.modoCreacion === 'CLONADO' &&
|
{wizard.tipoOrigen === 'CLONADO_INTERNO' && (
|
||||||
wizard.subModoClonado === 'INTERNO' && (
|
<div className="mt-2">
|
||||||
<div className="mt-2">
|
<span className="text-muted-foreground">Plan origen: </span>
|
||||||
<span className="text-muted-foreground">
|
<span className="font-medium">
|
||||||
Plan origen:{' '}
|
{(() => {
|
||||||
</span>
|
const p = PLANES_EXISTENTES.find(
|
||||||
<span className="font-medium">
|
(x) => x.id === wizard.clonInterno?.planOrigenId,
|
||||||
{(() => {
|
)
|
||||||
const p = PLANES_EXISTENTES.find(
|
return (
|
||||||
(x) => x.id === wizard.clonInterno?.planOrigenId,
|
p?.nombre || wizard.clonInterno?.planOrigenId || '—'
|
||||||
)
|
)
|
||||||
return (
|
})()}
|
||||||
p?.nombre || wizard.clonInterno?.planOrigenId || '—'
|
</span>
|
||||||
)
|
</div>
|
||||||
})()}
|
)}
|
||||||
</span>
|
{wizard.tipoOrigen === 'CLONADO_TRADICIONAL' && (
|
||||||
</div>
|
<div className="mt-2">
|
||||||
)}
|
<div className="font-medium">Documentos adjuntos</div>
|
||||||
{wizard.modoCreacion === 'CLONADO' &&
|
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||||
wizard.subModoClonado === 'TRADICIONAL' && (
|
<li>
|
||||||
<div className="mt-2">
|
<span className="text-foreground">Word del plan:</span>{' '}
|
||||||
<div className="font-medium">Documentos adjuntos</div>
|
{wizard.clonTradicional?.archivoWordPlanId?.name || '—'}
|
||||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">
|
||||||
Word del plan:
|
Mapa curricular:
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
{wizard.clonTradicional?.archivoWordPlanId?.name ||
|
{wizard.clonTradicional?.archivoMapaExcelId?.name ||
|
||||||
'—'}
|
'—'}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="text-foreground">
|
<span className="text-foreground">Asignaturas:</span>{' '}
|
||||||
Mapa curricular:
|
{wizard.clonTradicional?.archivoAsignaturasExcelId
|
||||||
</span>{' '}
|
?.name || '—'}
|
||||||
{wizard.clonTradicional?.archivoMapaExcelId?.name ||
|
</li>
|
||||||
'—'}
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
<li>
|
)}
|
||||||
<span className="text-foreground">Asignaturas:</span>{' '}
|
{wizard.tipoOrigen === 'IA' && (
|
||||||
{wizard.clonTradicional?.archivoAsignaturasExcelId
|
|
||||||
?.name || '—'}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{wizard.modoCreacion === 'IA' && (
|
|
||||||
<div className="bg-muted/50 mt-2 rounded-md p-3">
|
<div className="bg-muted/50 mt-2 rounded-md p-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Enfoque: </span>
|
<span className="text-muted-foreground">Enfoque: </span>
|
||||||
@@ -208,8 +168,10 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
|||||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||||
{adjuntos.map((f) => (
|
{adjuntos.map((f) => (
|
||||||
<li key={f.id}>
|
<li key={f.id}>
|
||||||
<span className="text-foreground">{f.name}</span>{' '}
|
<span className="text-foreground">
|
||||||
<span>· {f.size}</span>
|
{f.file.name}
|
||||||
|
</span>{' '}
|
||||||
|
<span>· {formatFileSize(f.file.size)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function StepWithTooltip({
|
|||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-[200px] text-xs">
|
<TooltipContent className="max-w-50 text-xs">
|
||||||
<p>{desc}</p>
|
<p>{desc}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,52 +1,54 @@
|
|||||||
import * as React from "react"
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { cva } from 'class-variance-authority'
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import * as React from 'react'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
icon: "size-9",
|
icon: 'size-9',
|
||||||
"icon-sm": "size-8",
|
'icon-sm': 'size-8',
|
||||||
"icon-lg": "size-10",
|
'icon-lg': 'size-10',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
size: "default",
|
size: 'default',
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
size = "default",
|
size = 'default',
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<'button'> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { supabaseBrowser } from '../supabase/client'
|
import { supabaseBrowser } from '../supabase/client'
|
||||||
import { invokeEdge } from '../supabase/invokeEdge'
|
import { invokeEdge } from '../supabase/invokeEdge'
|
||||||
import { buildRange, throwIfError, requireData } from './_helpers'
|
|
||||||
|
import { buildRange, requireData, throwIfError } from './_helpers'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Asignatura,
|
Asignatura,
|
||||||
CambioPlan,
|
CambioPlan,
|
||||||
@@ -12,12 +14,14 @@ import type {
|
|||||||
TipoCiclo,
|
TipoCiclo,
|
||||||
UUID,
|
UUID,
|
||||||
} from '../types/domain'
|
} from '../types/domain'
|
||||||
|
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||||
|
|
||||||
const EDGE = {
|
const EDGE = {
|
||||||
plans_create_manual: 'plans_create_manual',
|
plans_create_manual: 'plans_create_manual',
|
||||||
ai_generate_plan: 'ai_generate_plan',
|
ai_generate_plan: 'ai_generate_plan',
|
||||||
plans_persist_from_ai: 'plans_persist_from_ai',
|
plans_persist_from_ai: 'plans_persist_from_ai',
|
||||||
plans_clone_from_existing: 'plans_clone_from_existing',
|
plans_clone_from_existing: 'plans_clone_from_existing',
|
||||||
|
|
||||||
plans_import_from_files: 'plans_import_from_files',
|
plans_import_from_files: 'plans_import_from_files',
|
||||||
|
|
||||||
plans_update_fields: 'plans_update_fields',
|
plans_update_fields: 'plans_update_fields',
|
||||||
@@ -39,40 +43,82 @@ export type PlanListFilters = {
|
|||||||
offset?: number
|
offset?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper para limpiar texto (lo movemos fuera para reutilizar o lo dejas en un utils)
|
||||||
|
const cleanText = (text: string) => {
|
||||||
|
return text
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
export async function plans_list(
|
export async function plans_list(
|
||||||
filters: PlanListFilters = {},
|
filters: PlanListFilters = {},
|
||||||
): Promise<Paged<PlanEstudio>> {
|
): Promise<Paged<PlanEstudio>> {
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
// 1. Construimos la query base
|
||||||
|
// NOTA IMPORTANTE: Para filtrar planes basados en facultad (que está en carreras),
|
||||||
|
// necesitamos hacer un INNER JOIN. En Supabase se usa "!inner".
|
||||||
|
// Si filters.facultadId existe, forzamos el inner join, si no, lo dejamos normal.
|
||||||
|
|
||||||
|
const carreraModifier =
|
||||||
|
filters.facultadId && filters.facultadId !== 'todas' ? '!inner' : ''
|
||||||
|
|
||||||
let q = supabase
|
let q = supabase
|
||||||
.from('planes_estudio')
|
.from('planes_estudio')
|
||||||
.select(
|
.select(
|
||||||
`
|
`
|
||||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
*,
|
||||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)),
|
carreras${carreraModifier} (
|
||||||
estructuras_plan(id,nombre,tipo,version,definicion),
|
*,
|
||||||
estados_plan(id,clave,etiqueta,orden,es_final)
|
facultades (*)
|
||||||
`,
|
),
|
||||||
|
estructuras_plan (*),
|
||||||
|
estados_plan (*)
|
||||||
|
`,
|
||||||
{ count: 'exact' },
|
{ count: 'exact' },
|
||||||
)
|
)
|
||||||
.order('actualizado_en', { ascending: false })
|
.order('actualizado_en', { ascending: false })
|
||||||
|
|
||||||
if (filters.search?.trim())
|
// 2. Aplicamos filtros dinámicos
|
||||||
q = q.ilike('nombre', `%${filters.search.trim()}%`)
|
|
||||||
if (filters.carreraId) q = q.eq('carrera_id', filters.carreraId)
|
|
||||||
if (filters.estadoId) q = q.eq('estado_actual_id', filters.estadoId)
|
|
||||||
if (typeof filters.activo === 'boolean') q = q.eq('activo', filters.activo)
|
|
||||||
|
|
||||||
// filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos)
|
// SOLUCIÓN SEARCH: Limpiamos el input y buscamos en la columna generada
|
||||||
if (filters.facultadId) q = q.eq('carreras.facultad_id', filters.facultadId)
|
if (filters.search?.trim()) {
|
||||||
|
const cleanTerm = cleanText(filters.search.trim())
|
||||||
|
// Usamos la columna nueva creada en el Paso 1
|
||||||
|
q = q.ilike('nombre_search', `%${cleanTerm}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.carreraId && filters.carreraId !== 'todas') {
|
||||||
|
q = q.eq('carrera_id', filters.carreraId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.estadoId && filters.estadoId !== 'todos') {
|
||||||
|
q = q.eq('estado_actual_id', filters.estadoId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof filters.activo === 'boolean') {
|
||||||
|
q = q.eq('activo', filters.activo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtro por facultad (gracias al !inner arriba, esto filtrará los planes)
|
||||||
|
if (filters.facultadId && filters.facultadId !== 'todas') {
|
||||||
|
q = q.eq('carreras.facultad_id', filters.facultadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Paginación
|
||||||
const { from, to } = buildRange(filters.limit, filters.offset)
|
const { from, to } = buildRange(filters.limit, filters.offset)
|
||||||
if (typeof from === 'number' && typeof to === 'number') q = q.range(from, to)
|
if (from !== undefined && to !== undefined) q = q.range(from, to)
|
||||||
|
|
||||||
const { data, error, count } = await q
|
const { data, error, count } = await q
|
||||||
throwIfError(error)
|
throwIfError(error)
|
||||||
|
|
||||||
return { data: data ?? [], count: count ?? null }
|
return {
|
||||||
|
// 1. Si data es null, usa [].
|
||||||
|
// 2. Luego dile a TS que el resultado es tu Array tipado.
|
||||||
|
data: (data ?? []) as unknown as Array<PlanEstudio>,
|
||||||
|
count: count ?? 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||||
@@ -82,10 +128,10 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
|||||||
.from('planes_estudio')
|
.from('planes_estudio')
|
||||||
.select(
|
.select(
|
||||||
`
|
`
|
||||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
*,
|
||||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)),
|
carreras (*, facultades(*)),
|
||||||
estructuras_plan(id,nombre,tipo,template_id,definicion),
|
estructuras_plan (*),
|
||||||
estados_plan(id,clave,etiqueta,orden,es_final)
|
estados_plan (*)
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.eq('id', planId)
|
.eq('id', planId)
|
||||||
@@ -95,7 +141,9 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
|||||||
return requireData(data, 'Plan no encontrado.')
|
return requireData(data, 'Plan no encontrado.')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> {
|
export async function plan_lineas_list(
|
||||||
|
planId: UUID,
|
||||||
|
): Promise<Array<LineaPlan>> {
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('lineas_plan')
|
.from('lineas_plan')
|
||||||
@@ -109,7 +157,7 @@ export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> {
|
|||||||
|
|
||||||
export async function plan_asignaturas_list(
|
export async function plan_asignaturas_list(
|
||||||
planId: UUID,
|
planId: UUID,
|
||||||
): Promise<Asignatura[]> {
|
): Promise<Array<Asignatura>> {
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
@@ -125,7 +173,7 @@ export async function plan_asignaturas_list(
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function plans_history(planId: UUID): Promise<CambioPlan[]> {
|
export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('cambios_plan')
|
.from('cambios_plan')
|
||||||
@@ -170,8 +218,9 @@ export type AIGeneratePlanInput = {
|
|||||||
descripcionEnfoque: string
|
descripcionEnfoque: string
|
||||||
poblacionObjetivo?: string
|
poblacionObjetivo?: string
|
||||||
notasAdicionales?: string
|
notasAdicionales?: string
|
||||||
archivosReferencia?: UUID[]
|
archivosReferencia?: Array<UUID>
|
||||||
repositoriosIds?: UUID[]
|
repositoriosIds?: Array<UUID>
|
||||||
|
archivosAdjuntos: Array<UploadedFile>
|
||||||
usarMCP?: boolean
|
usarMCP?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,12 +295,12 @@ export type PlanMapOperation =
|
|||||||
op: 'REORDER_CELDA'
|
op: 'REORDER_CELDA'
|
||||||
linea_plan_id: UUID
|
linea_plan_id: UUID
|
||||||
numero_ciclo: number
|
numero_ciclo: number
|
||||||
asignaturaIdsOrdenados: UUID[]
|
asignaturaIdsOrdenados: Array<UUID>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function plans_update_map(
|
export async function plans_update_map(
|
||||||
planId: UUID,
|
planId: UUID,
|
||||||
ops: PlanMapOperation[],
|
ops: Array<PlanMapOperation>,
|
||||||
): Promise<{ ok: true }> {
|
): Promise<{ ok: true }> {
|
||||||
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops })
|
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops })
|
||||||
}
|
}
|
||||||
@@ -281,5 +330,28 @@ export async function plans_generate_document(
|
|||||||
export async function plans_get_document(
|
export async function plans_get_document(
|
||||||
planId: UUID,
|
planId: UUID,
|
||||||
): Promise<DocumentoResult | null> {
|
): Promise<DocumentoResult | null> {
|
||||||
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, { planId })
|
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, {
|
||||||
|
planId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCatalogos() {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const [facultadesRes, carrerasRes, estadosRes, estructurasPlanRes] =
|
||||||
|
await Promise.all([
|
||||||
|
supabase.from('facultades').select('*').order('nombre'),
|
||||||
|
supabase.from('carreras').select('*').order('nombre'),
|
||||||
|
supabase.from('estados_plan').select('*').order('orden'),
|
||||||
|
supabase.from('estructuras_plan').select('*').order('creado_en', {
|
||||||
|
ascending: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
facultades: facultadesRes.data ?? [],
|
||||||
|
carreras: carrerasRes.data ?? [],
|
||||||
|
estados: estadosRes.data ?? [],
|
||||||
|
estructurasPlan: estructurasPlanRes.data ?? [],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ai_plan_chat,
|
ai_plan_chat,
|
||||||
ai_plan_improve,
|
ai_plan_improve,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
} from '../api/plans.api'
|
} from '../api/plans.api'
|
||||||
import {
|
import {
|
||||||
ai_generate_plan,
|
ai_generate_plan,
|
||||||
|
getCatalogos,
|
||||||
plan_asignaturas_list,
|
plan_asignaturas_list,
|
||||||
plan_lineas_list,
|
plan_lineas_list,
|
||||||
plans_clone_from_existing,
|
plans_clone_from_existing,
|
||||||
@@ -33,8 +34,13 @@ import {
|
|||||||
export function usePlanes(filters: PlanListFilters) {
|
export function usePlanes(filters: PlanListFilters) {
|
||||||
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
|
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
// Usamos la factory de keys para consistencia
|
||||||
queryKey: qk.planesList(filters),
|
queryKey: qk.planesList(filters),
|
||||||
|
|
||||||
|
// La función fetch
|
||||||
queryFn: () => plans_list(filters),
|
queryFn: () => plans_list(filters),
|
||||||
|
|
||||||
|
// UX: Mantiene los datos viejos mientras carga la paginación nueva
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
||||||
export function getContext() {
|
export function getContext() {
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient(
|
||||||
|
{
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: (failureCount) => failureCount < 2,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
queryClient,
|
queryClient,
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "../types/database";
|
|
||||||
import { getEnv } from "./env";
|
import { getEnv } from "./env";
|
||||||
|
|
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database } from "src/types/supabase";
|
||||||
|
|
||||||
let _client: SupabaseClient<Database> | null = null;
|
let _client: SupabaseClient<Database> | null = null;
|
||||||
|
|
||||||
export function supabaseBrowser(): SupabaseClient<Database> {
|
export function supabaseBrowser(): SupabaseClient<Database> {
|
||||||
@@ -10,13 +13,13 @@ export function supabaseBrowser(): SupabaseClient<Database> {
|
|||||||
const url = getEnv(
|
const url = getEnv(
|
||||||
"VITE_SUPABASE_URL",
|
"VITE_SUPABASE_URL",
|
||||||
"NEXT_PUBLIC_SUPABASE_URL",
|
"NEXT_PUBLIC_SUPABASE_URL",
|
||||||
"SUPABASE_URL"
|
"SUPABASE_URL",
|
||||||
);
|
);
|
||||||
|
|
||||||
const anonKey = getEnv(
|
const anonKey = getEnv(
|
||||||
"VITE_SUPABASE_ANON_KEY",
|
"VITE_SUPABASE_ANON_KEY",
|
||||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||||
"SUPABASE_ANON_KEY"
|
"SUPABASE_ANON_KEY",
|
||||||
);
|
);
|
||||||
|
|
||||||
_client = createClient<Database>(url, anonKey, {
|
_client = createClient<Database>(url, anonKey, {
|
||||||
|
|||||||
@@ -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 UUID = string;
|
||||||
|
|
||||||
export type TipoEstructuraPlan = "CURRICULAR" | "NO_CURRICULAR";
|
export type TipoEstructuraPlan = Enums<"tipo_estructura_plan">;
|
||||||
export type NivelPlanEstudio =
|
export type NivelPlanEstudio = Enums<"nivel_plan_estudio">;
|
||||||
| "LICENCIATURA"
|
export type TipoCiclo = Enums<"tipo_ciclo">;
|
||||||
| "MAESTRIA"
|
|
||||||
| "DOCTORADO"
|
|
||||||
| "ESPECIALIDAD"
|
|
||||||
| "DIPLOMADO"
|
|
||||||
| "OTRO";
|
|
||||||
|
|
||||||
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE" | "OTRO";
|
export type TipoOrigen = Enums<"tipo_origen">;
|
||||||
|
|
||||||
export type TipoOrigen = "MANUAL" | "IA" | "CLONADO_INTERNO" | "TRADICIONAL" | "OTRO";
|
export type TipoAsignatura = Enums<"tipo_asignatura">;
|
||||||
|
|
||||||
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRA";
|
export type TipoBibliografia = Enums<"tipo_bibliografia">;
|
||||||
|
export type TipoFuenteBibliografia = Enums<"tipo_fuente_bibliografia">;
|
||||||
|
|
||||||
export type TipoBibliografia = "BASICA" | "COMPLEMENTARIA";
|
export type EstadoTareaRevision = Enums<"estado_tarea_revision">;
|
||||||
export type TipoFuenteBibliografia = "MANUAL" | "BIBLIOTECA";
|
export type TipoNotificacion = Enums<"tipo_notificacion">;
|
||||||
|
|
||||||
export type EstadoTareaRevision = "PENDIENTE" | "COMPLETADA" | "OMITIDA";
|
export type TipoInteraccionIA = Enums<"tipo_interaccion_ia">;
|
||||||
export type TipoNotificacion = "PLAN_ASIGNADO" | "ESTADO_CAMBIADO" | "TAREA_ASIGNADA" | "COMENTARIO" | "OTRA";
|
|
||||||
|
|
||||||
export type TipoInteraccionIA = "GENERAR" | "MEJORAR_SECCION" | "CHAT" | "OTRA";
|
|
||||||
|
|
||||||
export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta";
|
export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta";
|
||||||
export type DisenoCurricular = "Rígido" | "Flexible";
|
export type DisenoCurricular = "Rígido" | "Flexible";
|
||||||
@@ -58,218 +51,49 @@ export type PlanDatosSep = {
|
|||||||
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
|
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Paged<T> = { data: T[]; count: number | null };
|
export type PlanEstudioWithRel =
|
||||||
|
& Tables<"planes_estudio">
|
||||||
|
& {
|
||||||
|
carreras:
|
||||||
|
| Tables<"carreras"> & {
|
||||||
|
facultades: Tables<"facultades"> | null;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
estados_plan: Tables<"estados_plan"> | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type Facultad = {
|
export type Paged<T> = { data: Array<T>; count: number | null };
|
||||||
id: UUID;
|
|
||||||
nombre: string;
|
export type FacultadRow = Tables<"facultades">;
|
||||||
nombre_corto: string | null;
|
export type CarreraRow = Tables<"carreras">;
|
||||||
color: string | null;
|
|
||||||
icono: string | null;
|
export type EstructuraPlanRow = Tables<"estructuras_plan">;
|
||||||
creado_en: string;
|
|
||||||
actualizado_en: string;
|
export type EstructuraAsignatura = Tables<"estructuras_asignatura">;
|
||||||
|
|
||||||
|
export type EstadoPlanRow = Tables<"estados_plan">;
|
||||||
|
export type PlanEstudioRow = Tables<"planes_estudio">;
|
||||||
|
|
||||||
|
export type PlanEstudio = PlanEstudioRow & {
|
||||||
|
carreras: (CarreraRow & { facultades: FacultadRow | null }) | null;
|
||||||
|
estructuras_plan: EstructuraPlanRow | null;
|
||||||
|
estados_plan: EstadoPlanRow | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Carrera = {
|
export type LineaPlan = Tables<"lineas_plan">;
|
||||||
id: UUID;
|
|
||||||
facultad_id: UUID;
|
|
||||||
nombre: string;
|
|
||||||
nombre_corto: string | null;
|
|
||||||
clave_sep: string | null;
|
|
||||||
activa: boolean;
|
|
||||||
creado_en: string;
|
|
||||||
actualizado_en: string;
|
|
||||||
|
|
||||||
facultades?: Facultad | null;
|
export type Asignatura = Tables<"asignaturas">;
|
||||||
};
|
|
||||||
|
|
||||||
export type EstructuraPlan = {
|
export type BibliografiaAsignatura = Tables<"bibliografia_asignatura">;
|
||||||
id: UUID;
|
|
||||||
nombre: string;
|
|
||||||
tipo: TipoEstructuraPlan;
|
|
||||||
version: string | null;
|
|
||||||
definicion: Json;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EstructuraAsignatura = {
|
export type CambioPlan = Tables<"cambios_plan">;
|
||||||
id: UUID;
|
|
||||||
nombre: string;
|
|
||||||
version: string | null;
|
|
||||||
definicion: Json;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EstadoPlan = {
|
export type CambioAsignatura = Tables<"cambios_asignatura">;
|
||||||
id: UUID;
|
|
||||||
clave: string;
|
|
||||||
etiqueta: string;
|
|
||||||
orden: number;
|
|
||||||
es_final: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlanEstudio = {
|
export type InteraccionIA = Tables<"interacciones_ia">;
|
||||||
id: UUID;
|
|
||||||
carrera_id: UUID;
|
|
||||||
estructura_id: UUID;
|
|
||||||
|
|
||||||
nombre: string;
|
export type TareaRevision = Tables<"tareas_revision">;
|
||||||
nivel: NivelPlanEstudio;
|
|
||||||
tipo_ciclo: TipoCiclo;
|
|
||||||
numero_ciclos: number;
|
|
||||||
|
|
||||||
datos: Json;
|
export type Notificacion = Tables<"notificaciones">;
|
||||||
|
|
||||||
estado_actual_id: UUID | null;
|
export type Archivo = Tables<"archivos">;
|
||||||
activo: boolean;
|
|
||||||
|
|
||||||
tipo_origen: TipoOrigen | null;
|
|
||||||
meta_origen: Json;
|
|
||||||
|
|
||||||
creado_por: UUID | null;
|
|
||||||
actualizado_por: UUID | null;
|
|
||||||
|
|
||||||
creado_en: string;
|
|
||||||
actualizado_en: string;
|
|
||||||
|
|
||||||
carreras?: Carrera | null;
|
|
||||||
estructuras_plan?: EstructuraPlan | null;
|
|
||||||
estados_plan?: EstadoPlan | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LineaPlan = {
|
|
||||||
id: UUID;
|
|
||||||
plan_estudio_id: UUID;
|
|
||||||
nombre: string;
|
|
||||||
orden: number;
|
|
||||||
area: string | null;
|
|
||||||
creado_en: string;
|
|
||||||
actualizado_en: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Asignatura = {
|
|
||||||
id: UUID;
|
|
||||||
plan_estudio_id: UUID;
|
|
||||||
estructura_id: UUID | null;
|
|
||||||
|
|
||||||
facultad_propietaria_id: UUID | null;
|
|
||||||
|
|
||||||
codigo: string | null;
|
|
||||||
nombre: string;
|
|
||||||
|
|
||||||
tipo: TipoAsignatura;
|
|
||||||
creditos: number;
|
|
||||||
horas_semana: number | null;
|
|
||||||
|
|
||||||
numero_ciclo: number | null;
|
|
||||||
linea_plan_id: UUID | null;
|
|
||||||
orden_celda: number | null;
|
|
||||||
|
|
||||||
datos: Json;
|
|
||||||
contenido_tematico: Json;
|
|
||||||
|
|
||||||
tipo_origen: TipoOrigen | null;
|
|
||||||
meta_origen: Json;
|
|
||||||
|
|
||||||
creado_por: UUID | null;
|
|
||||||
actualizado_por: UUID | null;
|
|
||||||
|
|
||||||
creado_en: string;
|
|
||||||
actualizado_en: string;
|
|
||||||
|
|
||||||
planes_estudio?: PlanEstudio | null;
|
|
||||||
estructuras_asignatura?: EstructuraAsignatura | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BibliografiaAsignatura = {
|
|
||||||
id: UUID;
|
|
||||||
asignatura_id: UUID;
|
|
||||||
tipo: TipoBibliografia;
|
|
||||||
cita: string;
|
|
||||||
tipo_fuente: TipoFuenteBibliografia;
|
|
||||||
biblioteca_item_id: string | null;
|
|
||||||
|
|
||||||
creado_por: UUID | null;
|
|
||||||
creado_en: string;
|
|
||||||
actualizado_en: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CambioPlan = {
|
|
||||||
id: UUID;
|
|
||||||
plan_estudio_id: UUID;
|
|
||||||
cambiado_por: UUID | null;
|
|
||||||
cambiado_en: string;
|
|
||||||
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
|
|
||||||
campo: string | null;
|
|
||||||
valor_anterior: Json | null;
|
|
||||||
valor_nuevo: Json | null;
|
|
||||||
interaccion_ia_id: UUID | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CambioAsignatura = {
|
|
||||||
id: UUID;
|
|
||||||
asignatura_id: UUID;
|
|
||||||
cambiado_por: UUID | null;
|
|
||||||
cambiado_en: string;
|
|
||||||
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
|
|
||||||
campo: string | null;
|
|
||||||
valor_anterior: Json | null;
|
|
||||||
valor_nuevo: Json | null;
|
|
||||||
fuente: "HUMANO" | "IA" | null;
|
|
||||||
interaccion_ia_id: UUID | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InteraccionIA = {
|
|
||||||
id: UUID;
|
|
||||||
usuario_id: UUID | null;
|
|
||||||
plan_estudio_id: UUID | null;
|
|
||||||
asignatura_id: UUID | null;
|
|
||||||
|
|
||||||
tipo: TipoInteraccionIA;
|
|
||||||
modelo: string | null;
|
|
||||||
temperatura: number | null;
|
|
||||||
|
|
||||||
prompt: Json;
|
|
||||||
respuesta: Json;
|
|
||||||
|
|
||||||
aceptada: boolean;
|
|
||||||
|
|
||||||
conversacion_id: string | null;
|
|
||||||
ids_archivos: Json;
|
|
||||||
ids_vector_store: Json;
|
|
||||||
|
|
||||||
creado_en: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TareaRevision = {
|
|
||||||
id: UUID;
|
|
||||||
plan_estudio_id: UUID;
|
|
||||||
asignado_a: UUID;
|
|
||||||
rol_id: UUID | null;
|
|
||||||
estado_id: UUID | null;
|
|
||||||
estatus: EstadoTareaRevision;
|
|
||||||
fecha_limite: string | null;
|
|
||||||
creado_en: string;
|
|
||||||
completado_en: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Notificacion = {
|
|
||||||
id: UUID;
|
|
||||||
usuario_id: UUID;
|
|
||||||
tipo: TipoNotificacion;
|
|
||||||
payload: Json;
|
|
||||||
leida: boolean;
|
|
||||||
creado_en: string;
|
|
||||||
leida_en: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Archivo = {
|
|
||||||
id: UUID;
|
|
||||||
ruta_storage: string;
|
|
||||||
nombre: string;
|
|
||||||
mime_type: string | null;
|
|
||||||
bytes: number | null;
|
|
||||||
subido_por: UUID | null;
|
|
||||||
subido_en: string;
|
|
||||||
temporal: boolean;
|
|
||||||
openai_file_id: string | null;
|
|
||||||
notas: string | null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import * as Icons from 'lucide-react'
|
|||||||
|
|
||||||
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
|
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
|
||||||
|
|
||||||
|
import type { NewPlanWizardState } from './types'
|
||||||
|
|
||||||
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm'
|
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||||
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel'
|
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel'
|
||||||
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
|
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
|
||||||
@@ -49,7 +51,6 @@ export default function NuevoPlanModalContainer() {
|
|||||||
const {
|
const {
|
||||||
wizard,
|
wizard,
|
||||||
setWizard,
|
setWizard,
|
||||||
carrerasFiltradas,
|
|
||||||
canContinueDesdeModo,
|
canContinueDesdeModo,
|
||||||
canContinueDesdeBasicos,
|
canContinueDesdeBasicos,
|
||||||
canContinueDesdeDetalles,
|
canContinueDesdeDetalles,
|
||||||
@@ -61,12 +62,20 @@ export default function NuevoPlanModalContainer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const crearPlan = async () => {
|
const crearPlan = async () => {
|
||||||
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }))
|
setWizard((w: NewPlanWizardState) => ({
|
||||||
|
...w,
|
||||||
|
isLoading: true,
|
||||||
|
errorMessage: null,
|
||||||
|
}))
|
||||||
await new Promise((r) => setTimeout(r, 900))
|
await new Promise((r) => setTimeout(r, 900))
|
||||||
const nuevoId = (() => {
|
const nuevoId = (() => {
|
||||||
if (wizard.modoCreacion === 'MANUAL') return 'plan_new_manual_001'
|
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
|
||||||
if (wizard.modoCreacion === 'IA') return 'plan_new_ai_001'
|
if (wizard.tipoOrigen === 'IA') return 'plan_new_ai_001'
|
||||||
if (wizard.subModoClonado === 'INTERNO') return 'plan_new_clone_001'
|
if (
|
||||||
|
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||||
|
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
|
||||||
|
)
|
||||||
|
return 'plan_new_clone_001'
|
||||||
return 'plan_new_import_001'
|
return 'plan_new_import_001'
|
||||||
})()
|
})()
|
||||||
navigate({ to: `/planes/${nuevoId}` })
|
navigate({ to: `/planes/${nuevoId}` })
|
||||||
@@ -115,7 +124,10 @@ export default function NuevoPlanModalContainer() {
|
|||||||
{({ methods }) => {
|
{({ methods }) => {
|
||||||
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
|
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
|
||||||
const totalSteps = Wizard.steps.length
|
const totalSteps = Wizard.steps.length
|
||||||
const nextStep = Wizard.steps[currentIndex]
|
const nextStep = Wizard.steps[currentIndex] ?? {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -124,7 +136,7 @@ export default function NuevoPlanModalContainer() {
|
|||||||
totalSteps={totalSteps}
|
totalSteps={totalSteps}
|
||||||
currentTitle={methods.current.title}
|
currentTitle={methods.current.title}
|
||||||
currentDescription={methods.current.description}
|
currentDescription={methods.current.description}
|
||||||
nextTitle={nextStep?.title}
|
nextTitle={nextStep.title}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
Wizard={Wizard}
|
Wizard={Wizard}
|
||||||
/>
|
/>
|
||||||
@@ -144,7 +156,6 @@ export default function NuevoPlanModalContainer() {
|
|||||||
<PasoBasicosForm
|
<PasoBasicosForm
|
||||||
wizard={wizard}
|
wizard={wizard}
|
||||||
onChange={setWizard}
|
onChange={setWizard}
|
||||||
carrerasFiltradas={carrerasFiltradas}
|
|
||||||
/>
|
/>
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TipoCiclo } from "./types";
|
import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain";
|
||||||
|
|
||||||
export const FACULTADES = [
|
export const FACULTADES = [
|
||||||
{ id: "ing", nombre: "Facultad de Ingeniería" },
|
{ id: "ing", nombre: "Facultad de Ingeniería" },
|
||||||
@@ -16,16 +16,20 @@ export const CARRERAS = [
|
|||||||
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
|
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const NIVELES = [
|
export const NIVELES: Array<NivelPlanEstudio> = [
|
||||||
"Licenciatura",
|
"Licenciatura",
|
||||||
"Especialidad",
|
|
||||||
"Maestría",
|
"Maestría",
|
||||||
"Doctorado",
|
"Doctorado",
|
||||||
|
"Especialidad",
|
||||||
|
"Diplomado",
|
||||||
|
"Otro",
|
||||||
];
|
];
|
||||||
export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [
|
|
||||||
{ value: "SEMESTRE", label: "Semestre" },
|
export const TIPOS_CICLO: Array<TipoCiclo> = [
|
||||||
{ value: "CUATRIMESTRE", label: "Cuatrimestre" },
|
"Semestre",
|
||||||
{ value: "TRIMESTRE", label: "Trimestre" },
|
"Cuatrimestre",
|
||||||
|
"Trimestre",
|
||||||
|
"Otro",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PLANES_EXISTENTES = [
|
export const PLANES_EXISTENTES = [
|
||||||
|
|||||||
@@ -1,37 +1,33 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { CARRERAS } from "../catalogs";
|
import type { NewPlanWizardState, PlanPreview } from "../types";
|
||||||
|
import type { NivelPlanEstudio, TipoCiclo } from "@/data/types/domain";
|
||||||
import type { NewPlanWizardState, PlanPreview, TipoCiclo } from "../types";
|
|
||||||
|
|
||||||
export function useNuevoPlanWizard() {
|
export function useNuevoPlanWizard() {
|
||||||
const [wizard, setWizard] = useState<NewPlanWizardState>({
|
const [wizard, setWizard] = useState<NewPlanWizardState>({
|
||||||
step: 1,
|
step: 1,
|
||||||
modoCreacion: null,
|
tipoOrigen: null,
|
||||||
// datosBasicos: {
|
|
||||||
// nombrePlan: "",
|
|
||||||
// carreraId: "",
|
|
||||||
// facultadId: "",
|
|
||||||
// nivel: "",
|
|
||||||
// tipoCiclo: "",
|
|
||||||
// numCiclos: undefined,
|
|
||||||
// plantillaPlanId: "",
|
|
||||||
// plantillaPlanVersion: "",
|
|
||||||
// plantillaMapaId: "",
|
|
||||||
// plantillaMapaVersion: "",
|
|
||||||
// },
|
|
||||||
datosBasicos: {
|
datosBasicos: {
|
||||||
nombrePlan: "Medicina",
|
nombrePlan: "",
|
||||||
carreraId: "medico",
|
carreraId: "",
|
||||||
facultadId: "med",
|
facultadId: "",
|
||||||
nivel: "Licenciatura",
|
nivel: "",
|
||||||
tipoCiclo: "SEMESTRE",
|
tipoCiclo: "",
|
||||||
numCiclos: 8,
|
numCiclos: undefined,
|
||||||
plantillaPlanId: "sep-2025",
|
estructuraPlanId: null,
|
||||||
plantillaPlanVersion: "v2025.2 (Vigente)",
|
|
||||||
plantillaMapaId: "sep-2017-xlsx",
|
|
||||||
plantillaMapaVersion: "v2017.0",
|
|
||||||
},
|
},
|
||||||
|
// datosBasicos: {
|
||||||
|
// nombrePlan: "Medicina",
|
||||||
|
// carreraId: "medico",
|
||||||
|
// facultadId: "med",
|
||||||
|
// nivel: "Licenciatura",
|
||||||
|
// tipoCiclo: "SEMESTRE",
|
||||||
|
// numCiclos: 8,
|
||||||
|
// plantillaPlanId: "sep-2025",
|
||||||
|
// plantillaPlanVersion: "v2025.2 (Vigente)",
|
||||||
|
// plantillaMapaId: "sep-2017-xlsx",
|
||||||
|
// plantillaMapaVersion: "v2017.0",
|
||||||
|
// },
|
||||||
clonInterno: { planOrigenId: null },
|
clonInterno: { planOrigenId: null },
|
||||||
clonTradicional: {
|
clonTradicional: {
|
||||||
archivoWordPlanId: null,
|
archivoWordPlanId: null,
|
||||||
@@ -40,7 +36,6 @@ export function useNuevoPlanWizard() {
|
|||||||
},
|
},
|
||||||
iaConfig: {
|
iaConfig: {
|
||||||
descripcionEnfoque: "",
|
descripcionEnfoque: "",
|
||||||
poblacionObjetivo: "",
|
|
||||||
notasAdicionales: "",
|
notasAdicionales: "",
|
||||||
archivosReferencia: [],
|
archivosReferencia: [],
|
||||||
repositoriosReferencia: [],
|
repositoriosReferencia: [],
|
||||||
@@ -51,14 +46,10 @@ export function useNuevoPlanWizard() {
|
|||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const carrerasFiltradas = useMemo(() => {
|
const canContinueDesdeModo = wizard.tipoOrigen === "MANUAL" ||
|
||||||
const fac = wizard.datosBasicos.facultadId;
|
wizard.tipoOrigen === "IA" ||
|
||||||
return fac ? CARRERAS.filter((c) => c.facultadId === fac) : CARRERAS;
|
(wizard.tipoOrigen === "CLONADO_INTERNO" ||
|
||||||
}, [wizard.datosBasicos.facultadId]);
|
wizard.tipoOrigen === "CLONADO_TRADICIONAL");
|
||||||
|
|
||||||
const canContinueDesdeModo = wizard.modoCreacion === "MANUAL" ||
|
|
||||||
wizard.modoCreacion === "IA" ||
|
|
||||||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
|
|
||||||
|
|
||||||
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombrePlan &&
|
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombrePlan &&
|
||||||
!!wizard.datosBasicos.carreraId &&
|
!!wizard.datosBasicos.carreraId &&
|
||||||
@@ -67,30 +58,25 @@ export function useNuevoPlanWizard() {
|
|||||||
(wizard.datosBasicos.numCiclos !== undefined &&
|
(wizard.datosBasicos.numCiclos !== undefined &&
|
||||||
wizard.datosBasicos.numCiclos > 0) &&
|
wizard.datosBasicos.numCiclos > 0) &&
|
||||||
// Requerir ambas plantillas (plan y mapa) con versión
|
// Requerir ambas plantillas (plan y mapa) con versión
|
||||||
!!wizard.datosBasicos.plantillaPlanId &&
|
!!wizard.datosBasicos.estructuraPlanId;
|
||||||
!!wizard.datosBasicos.plantillaPlanVersion &&
|
|
||||||
!!wizard.datosBasicos.plantillaMapaId &&
|
|
||||||
!!wizard.datosBasicos.plantillaMapaVersion;
|
|
||||||
|
|
||||||
const canContinueDesdeDetalles = (() => {
|
const canContinueDesdeDetalles = (() => {
|
||||||
if (wizard.modoCreacion === "MANUAL") return true;
|
if (wizard.tipoOrigen === "MANUAL") return true;
|
||||||
if (wizard.modoCreacion === "IA") {
|
if (wizard.tipoOrigen === "IA") {
|
||||||
// Requerimos descripción del enfoque y notas adicionales
|
// Requerimos descripción del enfoque y notas adicionales
|
||||||
return !!wizard.iaConfig?.descripcionEnfoque &&
|
return !!wizard.iaConfig?.descripcionEnfoque &&
|
||||||
!!wizard.iaConfig?.notasAdicionales;
|
!!wizard.iaConfig.notasAdicionales;
|
||||||
}
|
}
|
||||||
if (wizard.modoCreacion === "CLONADO") {
|
if (wizard.tipoOrigen === "CLONADO_INTERNO") {
|
||||||
if (wizard.subModoClonado === "INTERNO") {
|
return !!wizard.clonInterno?.planOrigenId;
|
||||||
return !!wizard.clonInterno?.planOrigenId;
|
}
|
||||||
}
|
if (wizard.tipoOrigen === "CLONADO_TRADICIONAL") {
|
||||||
if (wizard.subModoClonado === "TRADICIONAL") {
|
const t = wizard.clonTradicional;
|
||||||
const t = wizard.clonTradicional;
|
if (!t) return false;
|
||||||
if (!t) return false;
|
const tieneWord = !!t.archivoWordPlanId;
|
||||||
const tieneWord = !!t.archivoWordPlanId;
|
const tieneAlMenosUnExcel = !!t.archivoMapaExcelId ||
|
||||||
const tieneAlMenosUnExcel = !!t.archivoMapaExcelId ||
|
!!t.archivoAsignaturasExcelId;
|
||||||
!!t.archivoAsignaturasExcelId;
|
return tieneWord && tieneAlMenosUnExcel;
|
||||||
return tieneWord && tieneAlMenosUnExcel;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
})();
|
})();
|
||||||
@@ -101,7 +87,7 @@ export function useNuevoPlanWizard() {
|
|||||||
// Ensure preview has the stricter types required by `PlanPreview`.
|
// Ensure preview has the stricter types required by `PlanPreview`.
|
||||||
let tipoCicloSafe: TipoCiclo;
|
let tipoCicloSafe: TipoCiclo;
|
||||||
if (wizard.datosBasicos.tipoCiclo === "") {
|
if (wizard.datosBasicos.tipoCiclo === "") {
|
||||||
tipoCicloSafe = "SEMESTRE";
|
tipoCicloSafe = "Semestre";
|
||||||
} else {
|
} else {
|
||||||
tipoCicloSafe = wizard.datosBasicos.tipoCiclo;
|
tipoCicloSafe = wizard.datosBasicos.tipoCiclo;
|
||||||
}
|
}
|
||||||
@@ -112,7 +98,7 @@ export function useNuevoPlanWizard() {
|
|||||||
|
|
||||||
const preview: PlanPreview = {
|
const preview: PlanPreview = {
|
||||||
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
|
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
|
||||||
nivel: wizard.datosBasicos.nivel || "Licenciatura",
|
nivel: wizard.datosBasicos.nivel as NivelPlanEstudio,
|
||||||
tipoCiclo: tipoCicloSafe,
|
tipoCiclo: tipoCicloSafe,
|
||||||
numCiclos: numCiclosSafe,
|
numCiclos: numCiclosSafe,
|
||||||
numAsignaturasAprox: numCiclosSafe * 6,
|
numAsignaturasAprox: numCiclosSafe * 6,
|
||||||
@@ -121,7 +107,7 @@ export function useNuevoPlanWizard() {
|
|||||||
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
|
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
setWizard((w) => ({
|
setWizard((w: NewPlanWizardState) => ({
|
||||||
...w,
|
...w,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
resumen: { previewPlan: preview },
|
resumen: { previewPlan: preview },
|
||||||
@@ -131,7 +117,6 @@ export function useNuevoPlanWizard() {
|
|||||||
return {
|
return {
|
||||||
wizard,
|
wizard,
|
||||||
setWizard,
|
setWizard,
|
||||||
carrerasFiltradas,
|
|
||||||
canContinueDesdeModo,
|
canContinueDesdeModo,
|
||||||
canContinueDesdeBasicos,
|
canContinueDesdeBasicos,
|
||||||
canContinueDesdeDetalles,
|
canContinueDesdeDetalles,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE";
|
import type { UploadedFile } from "@/components/planes/wizard/PasoDetallesPanel/FileDropZone";
|
||||||
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
|
import type {
|
||||||
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
|
NivelPlanEstudio,
|
||||||
|
TipoCiclo,
|
||||||
|
TipoOrigen,
|
||||||
|
} from "@/data/types/domain";
|
||||||
|
|
||||||
export type PlanPreview = {
|
export type PlanPreview = {
|
||||||
nombrePlan: string;
|
nombrePlan: string;
|
||||||
nivel: string;
|
nivel: NivelPlanEstudio;
|
||||||
tipoCiclo: TipoCiclo;
|
tipoCiclo: TipoCiclo;
|
||||||
numCiclos: number;
|
numCiclos: number;
|
||||||
numAsignaturasAprox?: number;
|
numAsignaturasAprox?: number;
|
||||||
@@ -13,20 +16,16 @@ export type PlanPreview = {
|
|||||||
|
|
||||||
export type NewPlanWizardState = {
|
export type NewPlanWizardState = {
|
||||||
step: 1 | 2 | 3 | 4;
|
step: 1 | 2 | 3 | 4;
|
||||||
modoCreacion: ModoCreacion | null;
|
tipoOrigen: TipoOrigen | null;
|
||||||
subModoClonado?: SubModoClonado;
|
|
||||||
datosBasicos: {
|
datosBasicos: {
|
||||||
nombrePlan: string;
|
nombrePlan: string;
|
||||||
carreraId: string;
|
carreraId: string;
|
||||||
facultadId: string;
|
facultadId: string;
|
||||||
nivel: string;
|
nivel: NivelPlanEstudio | "";
|
||||||
tipoCiclo: TipoCiclo | "";
|
tipoCiclo: TipoCiclo | "";
|
||||||
numCiclos: number | undefined;
|
numCiclos: number | undefined;
|
||||||
// Selección de plantillas (obligatorias)
|
// Selección de plantillas (obligatorias)
|
||||||
plantillaPlanId?: string;
|
estructuraPlanId: string | null;
|
||||||
plantillaPlanVersion?: string;
|
|
||||||
plantillaMapaId?: string;
|
|
||||||
plantillaMapaVersion?: string;
|
|
||||||
};
|
};
|
||||||
clonInterno?: { planOrigenId: string | null };
|
clonInterno?: { planOrigenId: string | null };
|
||||||
clonTradicional?: {
|
clonTradicional?: {
|
||||||
@@ -53,12 +52,11 @@ export type NewPlanWizardState = {
|
|||||||
};
|
};
|
||||||
iaConfig?: {
|
iaConfig?: {
|
||||||
descripcionEnfoque: string;
|
descripcionEnfoque: string;
|
||||||
poblacionObjetivo: string;
|
|
||||||
notasAdicionales: string;
|
notasAdicionales: string;
|
||||||
archivosReferencia: Array<string>;
|
archivosReferencia: Array<string>;
|
||||||
repositoriosReferencia?: Array<string>;
|
repositoriosReferencia?: Array<string>;
|
||||||
archivosAdjuntos?: Array<
|
archivosAdjuntos?: Array<
|
||||||
{ id: string; name: string; size: string; type: string }
|
UploadedFile
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
resumen: { previewPlan?: PlanPreview };
|
resumen: { previewPlan?: PlanPreview };
|
||||||
|
|||||||
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 { StrictMode } from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
import * as TanStackQueryProvider from './integrations/tanstack-query/root-provider.tsx'
|
|
||||||
import reportWebVitals from './reportWebVitals.ts'
|
import reportWebVitals from './reportWebVitals.ts'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
|
||||||
|
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
// Create a new router instance
|
// Create a new router instance
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as PlanesPlanesListRouteRouteImport } from './routes/planes/PlanesListRoute'
|
||||||
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
|
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
|
||||||
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
|
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
|
||||||
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
||||||
@@ -44,6 +45,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PlanesPlanesListRouteRoute = PlanesPlanesListRouteRouteImport.update({
|
||||||
|
id: '/planes/PlanesListRoute',
|
||||||
|
path: '/planes/PlanesListRoute',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
||||||
id: '/demo/tanstack-query',
|
id: '/demo/tanstack-query',
|
||||||
path: '/demo/tanstack-query',
|
path: '/demo/tanstack-query',
|
||||||
@@ -142,6 +148,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||||
|
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
|
||||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||||
@@ -162,6 +169,7 @@ export interface FileRoutesByTo {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||||
|
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
|
||||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
@@ -182,6 +190,7 @@ export interface FileRoutesById {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
|
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
|
||||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||||
|
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
|
||||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||||
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
||||||
@@ -205,6 +214,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/planes'
|
| '/planes'
|
||||||
| '/demo/tanstack-query'
|
| '/demo/tanstack-query'
|
||||||
|
| '/planes/PlanesListRoute'
|
||||||
| '/planes/$planId'
|
| '/planes/$planId'
|
||||||
| '/planes/$planId/asignaturas'
|
| '/planes/$planId/asignaturas'
|
||||||
| '/planes/nuevo'
|
| '/planes/nuevo'
|
||||||
@@ -225,6 +235,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/planes'
|
| '/planes'
|
||||||
| '/demo/tanstack-query'
|
| '/demo/tanstack-query'
|
||||||
|
| '/planes/PlanesListRoute'
|
||||||
| '/planes/$planId'
|
| '/planes/$planId'
|
||||||
| '/planes/nuevo'
|
| '/planes/nuevo'
|
||||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
@@ -244,6 +255,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/planes/_lista'
|
| '/planes/_lista'
|
||||||
| '/demo/tanstack-query'
|
| '/demo/tanstack-query'
|
||||||
|
| '/planes/PlanesListRoute'
|
||||||
| '/planes/$planId/_detalle'
|
| '/planes/$planId/_detalle'
|
||||||
| '/planes/$planId/asignaturas'
|
| '/planes/$planId/asignaturas'
|
||||||
| '/planes/_lista/nuevo'
|
| '/planes/_lista/nuevo'
|
||||||
@@ -266,6 +278,7 @@ export interface RootRouteChildren {
|
|||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
|
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
|
||||||
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
||||||
|
PlanesPlanesListRouteRoute: typeof PlanesPlanesListRouteRoute
|
||||||
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||||
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||||
}
|
}
|
||||||
@@ -293,6 +306,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/planes/PlanesListRoute': {
|
||||||
|
id: '/planes/PlanesListRoute'
|
||||||
|
path: '/planes/PlanesListRoute'
|
||||||
|
fullPath: '/planes/PlanesListRoute'
|
||||||
|
preLoaderRoute: typeof PlanesPlanesListRouteRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/demo/tanstack-query': {
|
'/demo/tanstack-query': {
|
||||||
id: '/demo/tanstack-query'
|
id: '/demo/tanstack-query'
|
||||||
path: '/demo/tanstack-query'
|
path: '/demo/tanstack-query'
|
||||||
@@ -486,6 +506,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
|
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
|
||||||
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
||||||
|
PlanesPlanesListRouteRoute: PlanesPlanesListRouteRoute,
|
||||||
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
|
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
|
||||||
PlanesPlanIdAsignaturasRouteRoute:
|
PlanesPlanIdAsignaturasRouteRoute:
|
||||||
PlanesPlanIdAsignaturasRouteRouteWithChildren,
|
PlanesPlanIdAsignaturasRouteRouteWithChildren,
|
||||||
|
|||||||
@@ -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 { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
|
||||||
import * as Icons from 'lucide-react'
|
import * as Icons from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useDebounce } from 'use-debounce'
|
||||||
import type { Option } from '@/components/planes/Filtro'
|
|
||||||
|
|
||||||
|
// Componentes
|
||||||
import BarraBusqueda from '@/components/planes/BarraBusqueda'
|
import BarraBusqueda from '@/components/planes/BarraBusqueda'
|
||||||
import Filtro from '@/components/planes/Filtro'
|
import Filtro from '@/components/planes/Filtro'
|
||||||
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
|
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
|
||||||
|
// Hooks y Utils (ajusta las rutas de importación)
|
||||||
|
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
|
||||||
|
import { getIconByName } from '@/features/planes/utils/icon-utils'
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/_lista')({
|
export const Route = createFileRoute('/planes/_lista')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -14,215 +17,104 @@ export const Route = createFileRoute('/planes/_lista')({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
type Facultad = { id: string; nombre: string; color: string }
|
|
||||||
type Carrera = { id: string; nombre: string; facultadId: string }
|
|
||||||
type Plan = {
|
|
||||||
id: string
|
|
||||||
icon: string
|
|
||||||
nombrePrograma: string
|
|
||||||
nivel: string
|
|
||||||
ciclos: string
|
|
||||||
facultadId: string
|
|
||||||
carreraId: string
|
|
||||||
estado:
|
|
||||||
| 'Aprobado'
|
|
||||||
| 'Pendiente'
|
|
||||||
| 'En proceso'
|
|
||||||
| 'Revisión expertos'
|
|
||||||
| 'Actualización'
|
|
||||||
claseColorEstado: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulación: datos provenientes de Supabase (hardcode)
|
// 1. Estados de Filtros
|
||||||
const facultades: Array<Facultad> = [
|
|
||||||
{ id: 'ing', nombre: 'Facultad de Ingeniería', color: '#2563eb' },
|
|
||||||
{ id: 'med', nombre: 'Facultad de Medicina', color: '#dc2626' },
|
|
||||||
{ id: 'neg', nombre: 'Facultad de Negocios', color: '#059669' },
|
|
||||||
{
|
|
||||||
id: 'arq',
|
|
||||||
nombre: 'Facultad Mexicana de Arquitectura, Diseño y Comunicación',
|
|
||||||
color: '#ea580c',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sal',
|
|
||||||
nombre: 'Escuela de Altos Estudios en Salud',
|
|
||||||
color: '#0891b2',
|
|
||||||
},
|
|
||||||
{ id: 'der', nombre: 'Facultad de Derecho', color: '#7c3aed' },
|
|
||||||
{ id: 'qui', nombre: 'Facultad de Ciencias Químicas', color: '#65a30d' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const carreras: Array<Carrera> = [
|
|
||||||
{
|
|
||||||
id: 'sis',
|
|
||||||
nombre: 'Ingeniería en Sistemas Computacionales',
|
|
||||||
facultadId: 'ing',
|
|
||||||
},
|
|
||||||
{ id: 'medico', nombre: 'Médico Cirujano', facultadId: 'med' },
|
|
||||||
{ id: 'act', nombre: 'Licenciatura en Actuaría', facultadId: 'neg' },
|
|
||||||
{ id: 'arq', nombre: 'Licenciatura en Arquitectura', facultadId: 'arq' },
|
|
||||||
{ id: 'fisio', nombre: 'Licenciatura en Fisioterapia', facultadId: 'sal' },
|
|
||||||
{ id: 'der', nombre: 'Licenciatura en Derecho', facultadId: 'der' },
|
|
||||||
{ id: 'qfb', nombre: 'Químico Farmacéutico Biólogo', facultadId: 'qui' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const estados: Array<Option> = [
|
|
||||||
{ value: 'todos', label: 'Todos los estados' },
|
|
||||||
{ value: 'Aprobado', label: 'Aprobado' },
|
|
||||||
{ value: 'Pendiente', label: 'Pendiente' },
|
|
||||||
{ value: 'En proceso', label: 'En proceso' },
|
|
||||||
{ value: 'Revisión expertos', label: 'Revisión expertos' },
|
|
||||||
{ value: 'Actualización', label: 'Actualización' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const planes: Array<Plan> = [
|
|
||||||
{
|
|
||||||
id: 'p1',
|
|
||||||
icon: 'Laptop',
|
|
||||||
nombrePrograma: 'Ingeniería en Sistemas Computacionales',
|
|
||||||
nivel: 'Licenciatura',
|
|
||||||
ciclos: '8 semestres',
|
|
||||||
facultadId: 'ing',
|
|
||||||
carreraId: 'sis',
|
|
||||||
estado: 'Revisión expertos',
|
|
||||||
claseColorEstado: 'bg-amber-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'p2',
|
|
||||||
icon: 'Stethoscope',
|
|
||||||
nombrePrograma: 'Médico Cirujano',
|
|
||||||
nivel: 'Licenciatura',
|
|
||||||
ciclos: '10 semestres',
|
|
||||||
facultadId: 'med',
|
|
||||||
carreraId: 'medico',
|
|
||||||
estado: 'Aprobado',
|
|
||||||
claseColorEstado: 'bg-emerald-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'p3',
|
|
||||||
icon: 'Calculator',
|
|
||||||
nombrePrograma: 'Licenciatura en Actuaría',
|
|
||||||
nivel: 'Licenciatura',
|
|
||||||
ciclos: '9 semestres',
|
|
||||||
facultadId: 'neg',
|
|
||||||
carreraId: 'act',
|
|
||||||
estado: 'Aprobado',
|
|
||||||
claseColorEstado: 'bg-emerald-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'p4',
|
|
||||||
icon: 'PencilRuler',
|
|
||||||
nombrePrograma: 'Licenciatura en Arquitectura',
|
|
||||||
nivel: 'Licenciatura',
|
|
||||||
ciclos: '10 semestres',
|
|
||||||
facultadId: 'arq',
|
|
||||||
carreraId: 'arq',
|
|
||||||
estado: 'En proceso',
|
|
||||||
claseColorEstado: 'bg-orange-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'p5',
|
|
||||||
icon: 'Activity',
|
|
||||||
nombrePrograma: 'Licenciatura en Fisioterapia',
|
|
||||||
nivel: 'Licenciatura',
|
|
||||||
ciclos: '8 semestres',
|
|
||||||
facultadId: 'sal',
|
|
||||||
carreraId: 'fisio',
|
|
||||||
estado: 'Revisión expertos',
|
|
||||||
claseColorEstado: 'bg-amber-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'p6',
|
|
||||||
icon: 'Scale',
|
|
||||||
nombrePrograma: 'Licenciatura en Derecho',
|
|
||||||
nivel: 'Licenciatura',
|
|
||||||
ciclos: '10 semestres',
|
|
||||||
facultadId: 'der',
|
|
||||||
carreraId: 'der',
|
|
||||||
estado: 'Pendiente',
|
|
||||||
claseColorEstado: 'bg-yellow-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'p7',
|
|
||||||
icon: 'FlaskConical',
|
|
||||||
nombrePrograma: 'Químico Farmacéutico Biólogo',
|
|
||||||
nivel: 'Licenciatura',
|
|
||||||
ciclos: '9 semestres',
|
|
||||||
facultadId: 'qui',
|
|
||||||
carreraId: 'qfb',
|
|
||||||
estado: 'Actualización',
|
|
||||||
claseColorEstado: 'bg-lime-600',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// Estado de filtros
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
// Debounce para evitar llamadas excesivas a la API
|
||||||
|
const [debouncedSearch] = useDebounce(search, 500)
|
||||||
|
|
||||||
const [facultadSel, setFacultadSel] = useState<string>('todas')
|
const [facultadSel, setFacultadSel] = useState<string>('todas')
|
||||||
const [carreraSel, setCarreraSel] = useState<string>('todas')
|
const [carreraSel, setCarreraSel] = useState<string>('todas')
|
||||||
const [estadoSel, setEstadoSel] = useState<string>('todos')
|
const [estadoSel, setEstadoSel] = useState<string>('todos')
|
||||||
|
|
||||||
// Opciones para filtros
|
// Paginación (opcional si la implementas en UI)
|
||||||
const facultadesOptions: Array<Option> = useMemo(
|
const [page, setPage] = useState(0)
|
||||||
|
const pageSize = 12
|
||||||
|
|
||||||
|
// 2. Carga de datos remotos
|
||||||
|
const { data: catalogos } = useCatalogosPlanes()
|
||||||
|
|
||||||
|
// Limpiamos el texto de búsqueda (quitar acentos) para enviarlo limpio a la API
|
||||||
|
// O lo puedes limpiar en el servicio. Aquí lo enviamos tal cual viene del debounce.
|
||||||
|
// Nota: Si usaste la solución "unaccent" en BD, envía el texto tal cual, postgres lo maneja.
|
||||||
|
const cleanSearchTerm = debouncedSearch.trim()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: planesData,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = usePlanes({
|
||||||
|
search: cleanSearchTerm,
|
||||||
|
facultadId: facultadSel,
|
||||||
|
carreraId: carreraSel,
|
||||||
|
estadoId: estadoSel,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: page * pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Preparación de Opciones para Selects (Derived State)
|
||||||
|
const facultadesOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ value: 'todas', label: 'Todas las facultades' },
|
{ value: 'todas', label: 'Todas las facultades' },
|
||||||
...facultades.map((f) => ({ value: f.id, label: f.nombre })),
|
...(catalogos?.facultades.map((f) => ({
|
||||||
|
value: f.id,
|
||||||
|
label: f.nombre,
|
||||||
|
})) ?? []),
|
||||||
],
|
],
|
||||||
[facultades],
|
[catalogos?.facultades],
|
||||||
)
|
)
|
||||||
|
|
||||||
const carrerasOptions: Array<Option> = useMemo(() => {
|
const carrerasOptions = useMemo(() => {
|
||||||
const list =
|
// Filtramos las carreras del catálogo base según la facultad seleccionada
|
||||||
|
const rawCarreras = catalogos?.carreras ?? []
|
||||||
|
const filtered =
|
||||||
facultadSel === 'todas'
|
facultadSel === 'todas'
|
||||||
? carreras
|
? rawCarreras
|
||||||
: carreras.filter((c) => c.facultadId === facultadSel)
|
: rawCarreras.filter((c) => c.facultad_id === facultadSel)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ value: 'todas', label: 'Todas las carreras' },
|
{ value: 'todas', label: 'Todas las carreras' },
|
||||||
...list.map((c) => ({ value: c.id, label: c.nombre })),
|
...filtered.map((c) => ({ value: c.id, label: c.nombre })),
|
||||||
]
|
]
|
||||||
}, [carreras, facultadSel])
|
}, [catalogos?.carreras, facultadSel])
|
||||||
|
|
||||||
// Filtrado de planes
|
const estadosOptions = useMemo(
|
||||||
const filteredPlans = useMemo(() => {
|
() => [
|
||||||
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
{ value: 'todos', label: 'Todos los estados' },
|
||||||
const cleanText = (text: string) => {
|
...(catalogos?.estados.map((e) => ({ value: e.id, label: e.etiqueta })) ??
|
||||||
return text
|
[]),
|
||||||
.normalize('NFD') // Descompone "á" en "a" + "´"
|
],
|
||||||
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
[catalogos?.estados],
|
||||||
.toLowerCase() // Convierte a minúsculas
|
)
|
||||||
}
|
|
||||||
// Limpiamos el término de búsqueda una sola vez antes de filtrar
|
|
||||||
const term = cleanText(search.trim())
|
|
||||||
return planes.filter((p) => {
|
|
||||||
const matchName = term
|
|
||||||
? // Limpiamos también el nombre del programa antes de comparar
|
|
||||||
cleanText(p.nombrePrograma).includes(term)
|
|
||||||
: true
|
|
||||||
const matchFac =
|
|
||||||
facultadSel === 'todas' ? true : p.facultadId === facultadSel
|
|
||||||
const matchCar =
|
|
||||||
carreraSel === 'todas' ? true : p.carreraId === carreraSel
|
|
||||||
const matchEst = estadoSel === 'todos' ? true : p.estado === estadoSel
|
|
||||||
return matchName && matchFac && matchCar && matchEst
|
|
||||||
})
|
|
||||||
}, [planes, search, facultadSel, carreraSel, estadoSel])
|
|
||||||
|
|
||||||
|
// 4. Handlers
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setSearch('')
|
setSearch('')
|
||||||
setFacultadSel('todas')
|
setFacultadSel('todas')
|
||||||
setCarreraSel('todas')
|
setCarreraSel('todas')
|
||||||
setEstadoSel('todos')
|
setEstadoSel('todos')
|
||||||
|
setPage(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearchChange = (val: string) => {
|
||||||
|
setSearch(val)
|
||||||
|
setPage(0) // Resetear página al buscar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderizado condicional básico
|
||||||
|
if (isError)
|
||||||
|
return <div className="p-8 text-red-500">Error cargando planes.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-background min-h-screen w-full">
|
<main className="bg-background min-h-screen w-full">
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
||||||
<div className="flex flex-col gap-4 lg:col-span-3">
|
<div className="flex flex-col gap-4 lg:col-span-3">
|
||||||
|
{/* Header y Botón Nuevo */}
|
||||||
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-xl">
|
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-xl">
|
||||||
<Icons.BookOpenText className="h-5 w-5" strokeWidth={2} />
|
<Icons.BookOpenText className="h-5 w-5" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-foreground text-2xl font-bold">
|
<h1 className="font-display text-foreground text-2xl font-bold">
|
||||||
Planes de Estudio
|
Planes de Estudio
|
||||||
@@ -232,31 +124,23 @@ function RouteComponent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
onClick={() => navigate({ to: '/planes/nuevo' })}
|
||||||
className={
|
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
||||||
'ring-offset-background focus-visible:ring-ring bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium whitespace-nowrap shadow-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0'
|
|
||||||
}
|
|
||||||
aria-label="Nuevo plan de estudios"
|
|
||||||
title="Nuevo plan de estudios"
|
|
||||||
onClick={() => {
|
|
||||||
navigate({ to: '/planes/nuevo' })
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Icons.Plus className="" />
|
<Icons.Plus /> Nuevo plan de estudios
|
||||||
Nuevo plan de estudios
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Barra de Filtros */}
|
||||||
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
|
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<BarraBusqueda
|
<BarraBusqueda
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
onChange={handleSearchChange}
|
||||||
placeholder="Buscar por programa…"
|
placeholder="Buscar por programa..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch justify-between gap-2 lg:flex-row lg:items-center">
|
<div className="flex flex-col items-stretch justify-between gap-2 lg:flex-row lg:items-center">
|
||||||
<div className="w-full lg:w-44">
|
<div className="w-full lg:w-44">
|
||||||
<Filtro
|
<Filtro
|
||||||
@@ -264,67 +148,93 @@ function RouteComponent() {
|
|||||||
value={facultadSel}
|
value={facultadSel}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setFacultadSel(v)
|
setFacultadSel(v)
|
||||||
// Reset carrera si ya no pertenece
|
|
||||||
setCarreraSel('todas')
|
setCarreraSel('todas')
|
||||||
|
setPage(0)
|
||||||
}}
|
}}
|
||||||
placeholder="Facultad"
|
placeholder="Facultad"
|
||||||
ariaLabel="Filtro por facultad"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full lg:w-44">
|
<div className="w-full lg:w-44">
|
||||||
<Filtro
|
<Filtro
|
||||||
options={carrerasOptions}
|
options={carrerasOptions}
|
||||||
value={carreraSel}
|
value={carreraSel}
|
||||||
onChange={setCarreraSel}
|
onChange={(v) => {
|
||||||
|
setCarreraSel(v)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
placeholder="Carrera"
|
placeholder="Carrera"
|
||||||
ariaLabel="Filtro por carrera"
|
|
||||||
disabled={facultadSel === 'todas'}
|
disabled={facultadSel === 'todas'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full lg:w-44">
|
<div className="w-full lg:w-44">
|
||||||
<Filtro
|
<Filtro
|
||||||
options={estados}
|
options={estadosOptions}
|
||||||
value={estadoSel}
|
value={estadoSel}
|
||||||
onChange={setEstadoSel}
|
onChange={(v) => {
|
||||||
|
setEstadoSel(v)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
placeholder="Estado"
|
placeholder="Estado"
|
||||||
ariaLabel="Filtro por estado"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
className={
|
className="ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors"
|
||||||
'ring-offset-background focus-visible:ring-ring bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium whitespace-nowrap shadow-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50'
|
|
||||||
}
|
|
||||||
title="Reiniciar filtros"
|
|
||||||
aria-label="Reiniciar filtros"
|
|
||||||
>
|
>
|
||||||
<Icons.X className="h-4 w-4" />
|
<Icons.X className="h-4 w-4" /> Limpiar
|
||||||
Limpiar
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
||||||
{filteredPlans.map((p) => {
|
{/* Grid de Resultados */}
|
||||||
const fac = facultades.find((f) => f.id === p.facultadId)!
|
{isLoading ? (
|
||||||
const IconComp = (Icons as any)[p.icon] ?? Icons.BookOpenText
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
return (
|
{/* Skeleton básico o Spinner */}
|
||||||
<PlanEstudiosCard
|
{[...Array(8)].map((_, i) => (
|
||||||
key={p.id}
|
<div
|
||||||
Icono={IconComp}
|
key={i}
|
||||||
nombrePrograma={p.nombrePrograma}
|
className="h-64 w-full animate-pulse rounded-xl bg-gray-100/50"
|
||||||
nivel={p.nivel}
|
|
||||||
ciclos={p.ciclos}
|
|
||||||
facultad={fac.nombre}
|
|
||||||
estado={p.estado}
|
|
||||||
claseColorEstado={p.claseColorEstado}
|
|
||||||
colorFacultad={fac.color}
|
|
||||||
onClick={() => console.log('Ver', p.nombrePrograma)}
|
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{planesData?.data.map((plan) => {
|
||||||
|
// Mapeo de datos: DB -> Props Componente
|
||||||
|
const facultad = plan.carreras?.facultades
|
||||||
|
const estado = plan.estados_plan
|
||||||
|
|
||||||
|
// NOTA: El color del estado no viene en BD por defecto,
|
||||||
|
// puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan'
|
||||||
|
// Aquí uso un fallback simple.
|
||||||
|
const estadoColor = estado?.es_final
|
||||||
|
? 'bg-emerald-600'
|
||||||
|
: 'bg-amber-600'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlanEstudiosCard
|
||||||
|
key={plan.id}
|
||||||
|
Icono={getIconByName(facultad?.icono ?? null)}
|
||||||
|
nombrePrograma={plan.nombre}
|
||||||
|
nivel={plan.nivel}
|
||||||
|
ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`}
|
||||||
|
facultad={facultad?.nombre ?? 'Sin Facultad'}
|
||||||
|
estado={estado?.etiqueta ?? 'Desconocido'}
|
||||||
|
claseColorEstado={estadoColor}
|
||||||
|
colorFacultad={facultad?.color ?? '#000000'}
|
||||||
|
onClick={() => console.log('Ver plan', plan.id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{planesData?.data.length === 0 && (
|
||||||
|
<div className="text-muted-foreground col-span-full py-10 text-center">
|
||||||
|
No se encontraron planes con estos filtros.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1293
src/types/supabase.ts
Normal file
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