Compare commits
13 Commits
52d36fe9f6
...
268d83fb4b
| Author | SHA1 | Date | |
|---|---|---|---|
| 268d83fb4b | |||
| 5a7672677d | |||
| 8d6b7c4ba9 | |||
| d6e1797f68 | |||
| 0a1ae7ce84 | |||
| 7fe4d8ace4 | |||
| 3e7d3385ec | |||
| 76db9d674b | |||
| b9c809e648 | |||
| cddc676f7d | |||
| edae79c255 | |||
| 69119aeaa6 | |||
| ef177a3f92 |
@@ -1,7 +1,7 @@
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun install
|
||||
RUN bunx --bun vite build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
@@ -17,5 +17,11 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {
|
||||
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
|
||||
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
|
||||
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
|
||||
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
|
||||
export default [
|
||||
@@ -24,9 +25,12 @@ export default [
|
||||
|
||||
// 3. TUS REGLAS Y CONFIGURACIÓN "PRO"
|
||||
{
|
||||
// Opcional: Puedes ser explícito sobre dónde aplicar esto
|
||||
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||
plugins: {
|
||||
'jsx-a11y': jsxA11y,
|
||||
'unused-imports': unusedImports,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
// Configuración robusta del Resolver (La versión de Copilot)
|
||||
settings: {
|
||||
@@ -44,7 +48,8 @@ export default [
|
||||
// --- REGLAS DE ACCESIBILIDAD (A11Y) ---
|
||||
// Activamos las recomendadas manualmente
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
// --- ORDEN DE IMPORTS ---
|
||||
'sort-imports': 'off', // Apagamos el nativo
|
||||
'import/order': [
|
||||
@@ -119,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,
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -43,10 +44,11 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.24.7",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tw-animate-css": "^1.3.6"
|
||||
},
|
||||
@@ -63,10 +65,12 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"supabase": "^2.72.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
NewSubjectWizardState,
|
||||
TipoAsignatura,
|
||||
} from '@/features/asignaturas/new/types'
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import {
|
||||
ESTRUCTURAS_SEP,
|
||||
TIPOS_MATERIA,
|
||||
} from '@/features/asignaturas/new/catalogs'
|
||||
} from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
FACULTADES,
|
||||
MATERIAS_MOCK,
|
||||
PLANES_MOCK,
|
||||
} from '@/features/asignaturas/new/catalogs'
|
||||
} from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoConfiguracionPanel({
|
||||
wizard,
|
||||
@@ -67,7 +67,7 @@ export function PasoConfiguracionPanel({
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="min-h-[100px]"
|
||||
className="min-h-25"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
@@ -213,7 +213,7 @@ export function PasoConfiguracionPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid max-h-[300px] gap-2 overflow-y-auto">
|
||||
<div className="grid max-h-75 gap-2 overflow-y-auto">
|
||||
{MATERIAS_MOCK.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
ModoCreacion,
|
||||
NewSubjectWizardState,
|
||||
SubModoClonado,
|
||||
} from '@/features/asignaturas/new/types'
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/new/catalogs'
|
||||
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
|
||||
return (
|
||||
|
||||
@@ -31,7 +31,7 @@ export function StepWithTooltip({
|
||||
{title}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[200px] text-xs">
|
||||
<TooltipContent className="max-w-50 text-xs">
|
||||
<p>{desc}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { CARRERAS } from '@/features/planes/new/catalogs'
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
FACULTADES,
|
||||
NIVELES,
|
||||
TIPOS_CICLO,
|
||||
} from '@/features/planes/new/catalogs'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
carrerasFiltradas,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
carrerasFiltradas: typeof CARRERAS
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="nombrePlan">Nombre del plan</Label>
|
||||
<Input
|
||||
id="nombrePlan"
|
||||
placeholder="Ej. Ingeniería en Sistemas 2026"
|
||||
value={wizard.datosBasicos.nombrePlan}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="facultad">Facultad</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
facultadId: value,
|
||||
carreraId: '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="facultad"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona facultad…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACULTADES.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="carrera">Carrera</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
||||
}))
|
||||
}
|
||||
disabled={!wizard.datosBasicos.facultadId}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="carrera"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona carrera…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{carrerasFiltradas.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="nivel">Nivel</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.nivel}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="nivel"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona nivel…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NIVELES.map((n) => (
|
||||
<SelectItem key={n} value={n}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.tipoCiclo}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipoCiclo: value as any,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="tipoCiclo"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_CICLO.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="numCiclos">Número de ciclos</Label>
|
||||
<Input
|
||||
id="numCiclos"
|
||||
type="number"
|
||||
min={1}
|
||||
value={wizard.datosBasicos.numCiclos}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
numCiclos: Number(e.target.value || 1),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
253
src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx
Normal file
253
src/components/planes/wizard/PasoBasicosForm/PasoBasicosForm.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { TemplateSelectorCard } from './TemplateSelectorCard'
|
||||
|
||||
import type { CARRERAS } from '@/features/planes/nuevo/catalogs'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
FACULTADES,
|
||||
NIVELES,
|
||||
TIPOS_CICLO,
|
||||
PLANTILLAS_ANEXO_1,
|
||||
PLANTILLAS_ANEXO_2,
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
carrerasFiltradas,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
carrerasFiltradas: typeof CARRERAS
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="nombrePlan">
|
||||
Nombre del plan <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="nombrePlan"
|
||||
placeholder="Ej. Ingeniería en Sistemas 2026"
|
||||
value={wizard.datosBasicos.nombrePlan}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="facultad">Facultad</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
facultadId: value,
|
||||
carreraId: '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="facultad"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.facultadId
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACULTADES.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="carrera">Carrera</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
||||
}))
|
||||
}
|
||||
disabled={!wizard.datosBasicos.facultadId}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="carrera"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.carreraId
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{carrerasFiltradas.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="nivel">Nivel</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.nivel}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="nivel"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.nivel
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Licenciatura" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NIVELES.map((n) => (
|
||||
<SelectItem key={n} value={n}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.tipoCiclo}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipoCiclo: value as any,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="tipoCiclo"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.tipoCiclo
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Semestre" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_CICLO.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="numCiclos">Número de ciclos</Label>
|
||||
<Input
|
||||
id="numCiclos"
|
||||
type="number"
|
||||
min={1}
|
||||
value={wizard.datosBasicos.numCiclos ?? ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
// Keep undefined when the input is empty so the field stays optional
|
||||
numCiclos:
|
||||
e.target.value === '' ? undefined : Number(e.target.value),
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
placeholder="Ej. 8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TemplateSelectorCard
|
||||
cardTitle="Plantilla de plan de estudios"
|
||||
cardDescription="Selecciona el Word para tu nuevo plan."
|
||||
templatesData={PLANTILLAS_ANEXO_1}
|
||||
selectedTemplateId={wizard.datosBasicos.plantillaPlanId || ''}
|
||||
selectedVersion={wizard.datosBasicos.plantillaPlanVersion || ''}
|
||||
onChange={({ templateId, version }) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
plantillaPlanId: templateId,
|
||||
plantillaPlanVersion: version,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<TemplateSelectorCard
|
||||
cardTitle="Plantilla de mapa curricular"
|
||||
cardDescription="Selecciona el Excel para tu mapa curricular."
|
||||
templatesData={PLANTILLAS_ANEXO_2}
|
||||
selectedTemplateId={wizard.datosBasicos.plantillaMapaId || ''}
|
||||
selectedVersion={wizard.datosBasicos.plantillaMapaVersion || ''}
|
||||
onChange={({ templateId, version }) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
plantillaMapaId: templateId,
|
||||
plantillaMapaVersion: version,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type TemplateData = {
|
||||
id: string
|
||||
name: string
|
||||
versions: Array<string>
|
||||
}
|
||||
|
||||
// Default data (kept for backward compatibility if caller doesn't pass templates)
|
||||
const DEFAULT_TEMPLATES_DATA: Array<TemplateData> = [
|
||||
{
|
||||
id: 'sep-2025',
|
||||
name: 'Licenciatura RVOE SEP',
|
||||
versions: ['v2025.2 (Vigente)', 'v2025.1', 'v2024.Final'],
|
||||
},
|
||||
{
|
||||
id: 'interno-mix',
|
||||
name: 'Estándar Institucional Mixto',
|
||||
versions: ['v2.0', 'v1.5', 'v1.0-beta'],
|
||||
},
|
||||
{
|
||||
id: 'conacyt',
|
||||
name: 'Formato Posgrado CONAHCYT',
|
||||
versions: ['v3.0 (2025)', 'v2.8'],
|
||||
},
|
||||
]
|
||||
|
||||
interface Props {
|
||||
cardTitle?: string
|
||||
cardDescription?: string
|
||||
templatesData?: Array<TemplateData>
|
||||
// Controlled selection (optional). If not provided, component manages its own state
|
||||
selectedTemplateId?: string
|
||||
selectedVersion?: string
|
||||
onChange?: (sel: { templateId: string; version: string }) => void
|
||||
}
|
||||
|
||||
export function TemplateSelectorCard({
|
||||
cardTitle = 'Configuración del Documento',
|
||||
cardDescription = 'Selecciona la base para tu nuevo plan.',
|
||||
templatesData = DEFAULT_TEMPLATES_DATA,
|
||||
selectedTemplateId,
|
||||
selectedVersion,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [internalTemplate, setInternalTemplate] = useState<string>('')
|
||||
const [internalVersion, setInternalVersion] = useState<string>('')
|
||||
|
||||
const selectedTemplate = selectedTemplateId ?? internalTemplate
|
||||
const version = selectedVersion ?? internalVersion
|
||||
|
||||
// Buscamos las versiones de la plantilla seleccionada
|
||||
const currentTemplateData = useMemo(
|
||||
() => templatesData.find((t) => t.id === selectedTemplate),
|
||||
[templatesData, selectedTemplate],
|
||||
)
|
||||
const availableVersions = currentTemplateData?.versions || []
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
const template = templatesData.find((t) => t.id === value)
|
||||
const firstVersion = template?.versions?.[0] ?? ''
|
||||
if (onChange) {
|
||||
onChange({ templateId: value, version: firstVersion })
|
||||
} else {
|
||||
setInternalTemplate(value)
|
||||
setInternalVersion(firstVersion)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVersionChange = (value: string) => {
|
||||
if (onChange) {
|
||||
onChange({ templateId: selectedTemplate, version: value })
|
||||
} else {
|
||||
setInternalVersion(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-lg gap-2 overflow-hidden">
|
||||
<CardHeader className="px-4 pb-2 sm:px-6 sm:pb-4">
|
||||
<CardTitle className="text-lg">{cardTitle}</CardTitle>
|
||||
<CardDescription>{cardDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{/* SELECT 1: PRIMARIO (Llamativo) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="template-select"
|
||||
className="text-foreground text-base font-semibold"
|
||||
>
|
||||
Plantilla
|
||||
</Label>
|
||||
<Select value={selectedTemplate} onValueChange={handleTemplateChange}>
|
||||
<SelectTrigger
|
||||
id="template-select"
|
||||
className="bg-background border-primary/40 focus:ring-primary/20 focus:border-primary flex h-11 w-full min-w-0 items-center justify-between gap-2 text-base shadow-sm [&>span]:block! [&>span]:truncate! [&>span]:text-left"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona una plantilla..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templatesData.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id} className="font-medium">
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* SELECT 2: SECUNDARIO (Sutil) */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="version-select"
|
||||
className={cn(
|
||||
'text-xs tracking-wider uppercase transition-colors',
|
||||
!selectedTemplate
|
||||
? 'text-muted-foreground/50'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
Versión
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={version}
|
||||
onValueChange={handleVersionChange}
|
||||
disabled={!selectedTemplate}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="version-select"
|
||||
className={cn(
|
||||
'flex h-9 min-w-0 items-center justify-between gap-2 text-sm transition-all duration-300',
|
||||
/* AQUÍ ESTÁ EL CAMBIO DE ANCHO: */
|
||||
'w-full max-w-full sm:w-55',
|
||||
|
||||
/* Las correcciones vitales para truncado que ya teníamos: */
|
||||
'min-w-0 [&>span]:block! [&>span]:truncate! [&>span]:text-left',
|
||||
'[&>span]:block [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left',
|
||||
|
||||
!selectedTemplate
|
||||
? 'bg-muted/50 cursor-not-allowed border-transparent opacity-50'
|
||||
: 'bg-muted/20 border-border hover:bg-background hover:border-primary/30',
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!selectedTemplate
|
||||
? '— Esperando plantilla —'
|
||||
: 'Selecciona versión'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVersions.map((v) => (
|
||||
<SelectItem
|
||||
key={v}
|
||||
value={v}
|
||||
className="text-muted-foreground focus:text-foreground text-sm"
|
||||
>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
205
src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx
Normal file
205
src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Upload, File, X, FileText } from 'lucide-react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
name: string
|
||||
size: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface FileDropzoneProps {
|
||||
onFilesChange?: (files: Array<UploadedFile>) => void
|
||||
acceptedTypes?: string
|
||||
maxFiles?: number
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
onFilesChange,
|
||||
acceptedTypes = '.doc,.docx,.pdf',
|
||||
maxFiles = 5,
|
||||
title = 'Arrastra archivos aquí',
|
||||
description = 'o haz clic para seleccionar',
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [files, setFiles] = useState<Array<UploadedFile>>([])
|
||||
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
|
||||
|
||||
const addFiles = useCallback(
|
||||
(newFiles: Array<File>) => {
|
||||
const toUpload: Array<UploadedFile> = newFiles.map((file) => ({
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}))
|
||||
setFiles((prev) => {
|
||||
const room = Math.max(0, maxFiles - prev.length)
|
||||
const next = [...prev, ...toUpload.slice(0, room)].slice(0, maxFiles)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[maxFiles],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||
addFiles(droppedFiles)
|
||||
},
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files)
|
||||
addFiles(selectedFiles)
|
||||
}
|
||||
},
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
setFiles((prev) => {
|
||||
const next = prev.filter((f) => f.id !== fileId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Keep latest callback in a ref to avoid retriggering effect on identity change
|
||||
useEffect(() => {
|
||||
onFilesChangeRef.current = onFilesChange
|
||||
}, [onFilesChange])
|
||||
|
||||
// Only emit when files actually change to avoid parent update loops
|
||||
useEffect(() => {
|
||||
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
|
||||
}, [files])
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return <FileText className="text-destructive h-4 w-4" />
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return <FileText className="text-info h-4 w-4" />
|
||||
default:
|
||||
return <File className="text-muted-foreground h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-border hover:border-primary/50 cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300',
|
||||
isDragging && 'active',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptedTypes}
|
||||
multiple
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="cursor-pointer"
|
||||
aria-label="Seleccionar archivos"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-xl transition-colors',
|
||||
isDragging
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Upload className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-foreground text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Formatos:{' '}
|
||||
{acceptedTypes
|
||||
.replace(/\./g, '')
|
||||
.toUpperCase()
|
||||
.replace(/,/g, ', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Uploaded files list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{getFileIcon(file.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{file.size}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => removeFile(file.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length >= maxFiles && (
|
||||
<p className="text-warning text-center text-xs">
|
||||
Máximo de {maxFiles} archivos alcanzado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
import { FileDropzone } from './FileDropZone'
|
||||
import ReferenciasParaIA from './ReferenciasParaIA'
|
||||
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -14,7 +17,7 @@ import {
|
||||
CARRERAS,
|
||||
FACULTADES,
|
||||
PLANES_EXISTENTES,
|
||||
} from '@/features/planes/new/catalogs'
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
export function PasoDetallesPanel({
|
||||
wizard,
|
||||
@@ -42,8 +45,8 @@ export function PasoDetallesPanel({
|
||||
|
||||
if (wizard.modoCreacion === 'IA') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="desc">Descripción del enfoque</Label>
|
||||
<textarea
|
||||
id="desc"
|
||||
@@ -61,24 +64,8 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="poblacion">Población objetivo</Label>
|
||||
<Input
|
||||
id="poblacion"
|
||||
placeholder="Ej. Egresados de bachillerato con perfil STEM"
|
||||
value={wizard.iaConfig?.poblacionObjetivo || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
poblacionObjetivo: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="notas">Notas adicionales</Label>
|
||||
<textarea
|
||||
id="notas"
|
||||
@@ -96,6 +83,49 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ReferenciasParaIA
|
||||
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
|
||||
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
||||
onToggleArchivo={(id, checked) =>
|
||||
onChange((w) => {
|
||||
const prev = w.iaConfig?.archivosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
: prev.filter((x) => x !== id)
|
||||
return {
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
archivosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onToggleRepositorio={(id, checked) =>
|
||||
onChange((w) => {
|
||||
const prev = w.iaConfig?.repositoriosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
: prev.filter((x) => x !== id)
|
||||
return {
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
repositoriosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onFilesChange={(files) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
archivosAdjuntos: files,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Opcional: se pueden adjuntar recursos IA más adelante.
|
||||
@@ -144,6 +174,7 @@ export function PasoDetallesPanel({
|
||||
<select
|
||||
id="clonFacultad"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label="Facultad"
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange((w) => ({
|
||||
@@ -168,6 +199,7 @@ export function PasoDetallesPanel({
|
||||
<select
|
||||
id="clonCarrera"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label="Carrera"
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange((w) => ({
|
||||
@@ -242,10 +274,10 @@ export function PasoDetallesPanel({
|
||||
wizard.subModoClonado === 'TRADICIONAL'
|
||||
) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="word">Word del plan (obligatorio)</Label>
|
||||
<input
|
||||
{/* <input
|
||||
id="word"
|
||||
type="file"
|
||||
accept=".doc,.docx"
|
||||
@@ -261,6 +293,21 @@ export function PasoDetallesPanel({
|
||||
},
|
||||
}))
|
||||
}
|
||||
/> */}
|
||||
|
||||
<FileDropzone
|
||||
acceptedTypes=".doc,.docx"
|
||||
maxFiles={1}
|
||||
onFilesChange={(files) => {
|
||||
const f = files[0] || null
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoWordPlanId: f,
|
||||
},
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -269,17 +316,32 @@ export function PasoDetallesPanel({
|
||||
id="mapa"
|
||||
type="file"
|
||||
accept=".xls,.xlsx"
|
||||
title="Subir mapa curricular"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
onChange((w) => {
|
||||
const file = e.target.files?.[0] || null
|
||||
const next = file
|
||||
? {
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}
|
||||
: null
|
||||
return {
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoMapaExcelId: e.target.files?.[0]
|
||||
? `file_${e.target.files[0].name}`
|
||||
: null,
|
||||
archivoMapaExcelId: next,
|
||||
},
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -289,17 +351,32 @@ export function PasoDetallesPanel({
|
||||
id="asignaturas"
|
||||
type="file"
|
||||
accept=".xls,.xlsx,.csv"
|
||||
title="Subir listado de asignaturas"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
onChange((w) => {
|
||||
const file = e.target.files?.[0] || null
|
||||
const next = file
|
||||
? {
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}
|
||||
: null
|
||||
return {
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...(w.clonTradicional || ({} as any)),
|
||||
archivoAsignaturasExcelId: e.target.files?.[0]
|
||||
? `file_${e.target.files[0].name}`
|
||||
: null,
|
||||
archivoAsignaturasExcelId: next,
|
||||
},
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -321,3 +398,9 @@ export function PasoDetallesPanel({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { FileText, FolderOpen, Upload } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import BarraBusqueda from '../../BarraBusqueda'
|
||||
|
||||
import { FileDropzone } from './FileDropZone'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContents,
|
||||
} from '@/components/ui/motion-tabs'
|
||||
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
const ReferenciasParaIA = ({
|
||||
selectedArchivoIds = [],
|
||||
selectedRepositorioIds = [],
|
||||
onToggleArchivo,
|
||||
onToggleRepositorio,
|
||||
onFilesChange,
|
||||
}: {
|
||||
selectedArchivoIds?: Array<string>
|
||||
selectedRepositorioIds?: Array<string>
|
||||
onToggleArchivo?: (id: string, checked: boolean) => void
|
||||
onToggleRepositorio?: (id: string, checked: boolean) => void
|
||||
onFilesChange?: (
|
||||
files: Array<{ id: string; name: string; size: string; type: string }>,
|
||||
) => void
|
||||
}) => {
|
||||
const [busquedaArchivos, setBusquedaArchivos] = useState('')
|
||||
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
|
||||
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD') // Descompone "á" en "a" + "´"
|
||||
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
||||
.toLowerCase() // Convierte a minúsculas
|
||||
}
|
||||
|
||||
// Filtrado de archivos y de repositorios
|
||||
const archivosFiltrados = useMemo(() => {
|
||||
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
||||
|
||||
const term = cleanText(busquedaArchivos)
|
||||
return ARCHIVOS.filter((archivo) =>
|
||||
cleanText(archivo.nombre).includes(term),
|
||||
)
|
||||
}, [busquedaArchivos])
|
||||
|
||||
const repositoriosFiltrados = useMemo(() => {
|
||||
const term = cleanText(busquedaRepositorios)
|
||||
return REPOSITORIOS.filter((repositorio) =>
|
||||
cleanText(repositorio.nombre).includes(term),
|
||||
)
|
||||
}, [busquedaRepositorios])
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Archivos existentes',
|
||||
|
||||
value: 'archivos-existentes',
|
||||
|
||||
icon: FileText,
|
||||
|
||||
content: (
|
||||
<div className="flex flex-col">
|
||||
<BarraBusqueda
|
||||
value={busquedaArchivos}
|
||||
onChange={setBusquedaArchivos}
|
||||
placeholder="Buscar archivo existente..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
{archivosFiltrados.map((archivo) => (
|
||||
<Label
|
||||
key={archivo.id}
|
||||
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedArchivoIds.includes(archivo.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleArchivo?.(archivo.id, !!checked)
|
||||
}
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{archivo.nombre}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{archivo.tamaño}
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Repositorios',
|
||||
|
||||
value: 'repositorios',
|
||||
|
||||
icon: FolderOpen,
|
||||
|
||||
content: (
|
||||
<div className="flex flex-col">
|
||||
<BarraBusqueda
|
||||
value={busquedaRepositorios}
|
||||
onChange={setBusquedaRepositorios}
|
||||
placeholder="Buscar repositorio..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
{repositoriosFiltrados.map((repositorio) => (
|
||||
<Label
|
||||
key={repositorio.id}
|
||||
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRepositorioIds.includes(repositorio.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleRepositorio?.(repositorio.id, !!checked)
|
||||
}
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
|
||||
<FolderOpen className="text-muted-foreground h-4 w-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{repositorio.nombre}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{repositorio.descripcion} · {repositorio.cantidadArchivos}{' '}
|
||||
archivos
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Subir archivos',
|
||||
|
||||
value: 'subir-archivos',
|
||||
|
||||
icon: Upload,
|
||||
|
||||
content: (
|
||||
<div>
|
||||
<FileDropzone
|
||||
onFilesChange={onFilesChange}
|
||||
title="Sube archivos de referencia"
|
||||
description="Documentos que serán usados como contexto para la generación"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Label>Referencias para la IA</Label>
|
||||
|
||||
<Tabs defaultValue="archivos-existentes" className="gap-4">
|
||||
<TabsList className="w-full">
|
||||
{tabs.map(({ icon: Icon, name, value }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="flex items-center gap-1 px-2.5 sm:px-3"
|
||||
>
|
||||
<Icon />
|
||||
|
||||
<span className="hidden sm:inline">{name}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContents className="bg-background mx-1 -mt-2 mb-1 h-full rounded-sm">
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="animate-in fade-in duration-300 ease-out"
|
||||
>
|
||||
{tab.content}
|
||||
</TabsContent>
|
||||
))}
|
||||
</TabsContents>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReferenciasParaIA
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
NewPlanWizardState,
|
||||
ModoCreacion,
|
||||
SubModoClonado,
|
||||
} from '@/features/planes/new/types'
|
||||
} from '@/features/planes/nuevo/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -23,6 +23,19 @@ export function PasoModoCardGroup({
|
||||
}) {
|
||||
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
|
||||
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
|
||||
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
|
||||
const key = e.key
|
||||
if (
|
||||
key === 'Enter' ||
|
||||
key === ' ' ||
|
||||
key === 'Spacebar' ||
|
||||
key === 'Space'
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cb()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card
|
||||
@@ -34,6 +47,15 @@ export function PasoModoCardGroup({
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'MANUAL',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -54,6 +76,15 @@ export function PasoModoCardGroup({
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'IA',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -70,6 +101,11 @@ export function PasoModoCardGroup({
|
||||
<Card
|
||||
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, modoCreacion: 'CLONADO' })),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -88,6 +124,11 @@ export function PasoModoCardGroup({
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('INTERNO')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
@@ -105,6 +146,11 @@ export function PasoModoCardGroup({
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('TRADICIONAL')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -7,10 +7,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
PLANTILLAS_ANEXO_1,
|
||||
PLANTILLAS_ANEXO_2,
|
||||
PLANES_EXISTENTES,
|
||||
ARCHIVOS,
|
||||
REPOSITORIOS,
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
const modo = wizard.modoCreacion
|
||||
const sub = wizard.subModoClonado
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -21,6 +26,20 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 text-sm">
|
||||
{(() => {
|
||||
// Precompute common derived values to avoid unnecessary optional chaining warnings
|
||||
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
|
||||
const repositoriosRef =
|
||||
wizard.iaConfig?.repositoriosReferencia ?? []
|
||||
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
|
||||
const plantillaPlan = PLANTILLAS_ANEXO_1.find(
|
||||
(x) => x.id === wizard.datosBasicos.plantillaPlanId,
|
||||
)
|
||||
const plantillaMapa = PLANTILLAS_ANEXO_2.find(
|
||||
(x) => x.id === wizard.datosBasicos.plantillaMapaId,
|
||||
)
|
||||
const contenido = (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Nombre: </span>
|
||||
<span className="font-medium">
|
||||
@@ -28,7 +47,9 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Facultad/Carrera: </span>
|
||||
<span className="text-muted-foreground">
|
||||
Facultad/Carrera:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.facultadId || '—'} /{' '}
|
||||
{wizard.datosBasicos.carreraId || '—'}
|
||||
@@ -43,22 +64,159 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
<div>
|
||||
<span className="text-muted-foreground">Ciclos: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.numCiclos} ({wizard.datosBasicos.tipoCiclo})
|
||||
{wizard.datosBasicos.numCiclos} (
|
||||
{wizard.datosBasicos.tipoCiclo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
Plantilla plan:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaPlan?.name ||
|
||||
wizard.datosBasicos.plantillaPlanId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaPlanVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Mapa curricular:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaMapa?.name ||
|
||||
wizard.datosBasicos.plantillaMapaId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaMapaVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">Modo: </span>
|
||||
<span className="font-medium">
|
||||
{modo === 'MANUAL' && 'Manual'}
|
||||
{modo === 'IA' && 'Generado con IA'}
|
||||
{modo === 'CLONADO' &&
|
||||
sub === 'INTERNO' &&
|
||||
{wizard.modoCreacion === 'MANUAL' && 'Manual'}
|
||||
{wizard.modoCreacion === 'IA' && 'Generado con IA'}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' &&
|
||||
'Clonado desde plan del sistema'}
|
||||
{modo === 'CLONADO' &&
|
||||
sub === 'TRADICIONAL' &&
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL' &&
|
||||
'Importado desde documentos tradicionales'}
|
||||
</span>
|
||||
</div>
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' && (
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
Plan origen:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(() => {
|
||||
const p = PLANES_EXISTENTES.find(
|
||||
(x) => x.id === wizard.clonInterno?.planOrigenId,
|
||||
)
|
||||
return (
|
||||
p?.nombre || wizard.clonInterno?.planOrigenId || '—'
|
||||
)
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL' && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Documentos adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Word del plan:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoWordPlanId?.name ||
|
||||
'—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Mapa curricular:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoMapaExcelId?.name ||
|
||||
'—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">Asignaturas:</span>{' '}
|
||||
{wizard.clonTradicional?.archivoAsignaturasExcelId
|
||||
?.name || '—'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'IA' && (
|
||||
<div className="bg-muted/50 mt-2 rounded-md p-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Enfoque: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.descripcionEnfoque || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Notas: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.notasAdicionales || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{archivosRef.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Archivos existentes</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{archivosRef.map((id) => {
|
||||
const a = ARCHIVOS.find((x) => x.id === id)
|
||||
return (
|
||||
<li key={id}>
|
||||
<span className="text-foreground">
|
||||
{a?.nombre || id}
|
||||
</span>{' '}
|
||||
{a?.tamaño ? <span>· {a.tamaño}</span> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{repositoriosRef.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Repositorios</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{repositoriosRef.map((id) => {
|
||||
const r = REPOSITORIOS.find((x) => x.id === id)
|
||||
return (
|
||||
<li key={id}>
|
||||
<span className="text-foreground">
|
||||
{r?.nombre || id}
|
||||
</span>{' '}
|
||||
{r?.cantidadArchivos ? (
|
||||
<span>· {r.cantidadArchivos} archivos</span>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{adjuntos.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{adjuntos.map((f) => (
|
||||
<li key={f.id}>
|
||||
<span className="text-foreground">{f.name}</span>{' '}
|
||||
<span>· {f.size}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{wizard.resumen.previewPlan && (
|
||||
<div className="bg-muted mt-2 rounded-md p-3">
|
||||
<div className="font-medium">Preview IA</div>
|
||||
@@ -68,6 +226,10 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return contenido
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function StepWithTooltip({
|
||||
{title}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[200px] text-xs">
|
||||
<TooltipContent className="max-w-50 text-xs">
|
||||
<p>{desc}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
32
src/components/shadcn-studio/checkbox/checkbox-13.tsx
Normal file
32
src/components/shadcn-studio/checkbox/checkbox-13.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const CheckboxCardDemo = () => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="border-border hover:border-primary/30 hover:bg-accent/50 flex cursor-pointer items-center items-start gap-2 gap-3 rounded-lg border p-3 transition-colors has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
|
||||
<Checkbox
|
||||
defaultChecked
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<div className="grid gap-1.5 font-normal">
|
||||
<p className="text-sm leading-none font-medium">Auto Start</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Starting with your OS.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
<Label className="hover:bg-accent/50 flex items-start gap-2 rounded-lg border p-3 has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
|
||||
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
|
||||
<div className="grid gap-1.5 font-normal">
|
||||
<p className="text-sm leading-none font-medium">Auto update</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Download and install new version
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CheckboxCardDemo
|
||||
76
src/components/shadcn-studio/tabs/tabs-03.tsx
Normal file
76
src/components/shadcn-studio/tabs/tabs-03.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BookIcon, GiftIcon, HeartIcon } from 'lucide-react'
|
||||
|
||||
import CheckboxCardDemo from '../checkbox/checkbox-13'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Explore',
|
||||
value: 'explore',
|
||||
icon: BookIcon,
|
||||
content: (
|
||||
<>
|
||||
<CheckboxCardDemo />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Favorites',
|
||||
value: 'favorites',
|
||||
icon: HeartIcon,
|
||||
content: (
|
||||
<>
|
||||
All your{' '}
|
||||
<span className="text-foreground font-semibold">favorites</span> are
|
||||
saved here. Revisit articles, collections, and moments you love, any
|
||||
time you want a little inspiration.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Surprise',
|
||||
value: 'surprise',
|
||||
icon: GiftIcon,
|
||||
content: (
|
||||
<>
|
||||
<span className="text-foreground font-semibold">Surprise!</span>{' '}
|
||||
Here's something unexpected—a fun fact, a quirky tip, or a daily
|
||||
challenge. Come back for a new surprise every day!
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const TabsWithIconDemo = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Tabs defaultValue="explore" className="gap-4">
|
||||
<TabsList className="w-full">
|
||||
{tabs.map(({ icon: Icon, name, value }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="flex items-center gap-1 px-2.5 sm:px-3"
|
||||
>
|
||||
<Icon />
|
||||
{name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="animate-in fade-in duration-300 ease-out"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">{tab.content}</p>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabsWithIconDemo
|
||||
72
src/components/shadcn-studio/tabs/tabs-27.tsx
Normal file
72
src/components/shadcn-studio/tabs/tabs-27.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from '@/components/ui/motion-tabs'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Explore',
|
||||
value: 'explore',
|
||||
content: (
|
||||
<>
|
||||
Discover <span className='text-foreground font-semibold'>fresh ideas</span>, trending topics, and hidden gems
|
||||
curated just for you. Start exploring and let your curiosity lead the way!
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'Favorites',
|
||||
value: 'favorites',
|
||||
content: (
|
||||
<>
|
||||
All your <span className='text-foreground font-semibold'>favorites</span> are saved here. Revisit articles,
|
||||
collections, and moments you love, any time you want a little inspiration.
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'Surprise Me',
|
||||
value: 'surprise',
|
||||
content: (
|
||||
<>
|
||||
<span className='text-foreground font-semibold'>Surprise!</span> Here's something unexpected—a fun fact, a
|
||||
quirky tip, or a daily challenge. Come back for a new surprise every day!
|
||||
</>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const AnimatedTabsDemo = () => {
|
||||
return (
|
||||
<div className='w-full max-w-md'>
|
||||
<Tabs defaultValue='explore' className='gap-4'>
|
||||
<TabsList>
|
||||
{tabs.map(tab => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContents className='bg-background mx-1 -mt-2 mb-1 h-full rounded-sm'>
|
||||
{tabs.map(tab => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
<p className='text-muted-foreground text-sm'>{tab.content}</p>
|
||||
</TabsContent>
|
||||
))}
|
||||
</TabsContents>
|
||||
</Tabs>
|
||||
|
||||
<p className='text-muted-foreground mt-4 text-center text-xs'>
|
||||
Inspired by{' '}
|
||||
<a
|
||||
className='hover:text-foreground underline'
|
||||
href='https://animate-ui.com/docs/components/tabs'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Animate UI
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnimatedTabsDemo
|
||||
@@ -1,52 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
549
src/components/ui/motion-highlight.tsx
Normal file
549
src/components/ui/motion-highlight.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import type { Transition } from 'motion/react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MotionHighlightMode = 'children' | 'parent'
|
||||
|
||||
type Bounds = {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type MotionHighlightContextType<T extends string> = {
|
||||
mode: MotionHighlightMode
|
||||
activeValue: T | null
|
||||
setActiveValue: (value: T | null) => void
|
||||
setBounds: (bounds: DOMRect) => void
|
||||
clearBounds: () => void
|
||||
id: string
|
||||
hover: boolean
|
||||
className?: string
|
||||
activeClassName?: string
|
||||
setActiveClassName: (className: string) => void
|
||||
transition?: Transition
|
||||
disabled?: boolean
|
||||
enabled?: boolean
|
||||
exitDelay?: number
|
||||
forceUpdateBounds?: boolean
|
||||
}
|
||||
|
||||
const MotionHighlightContext = React.createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
MotionHighlightContextType<any> | undefined
|
||||
>(undefined)
|
||||
|
||||
function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> {
|
||||
const context = React.useContext(MotionHighlightContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useMotionHighlight must be used within a MotionHighlightProvider')
|
||||
}
|
||||
|
||||
return context as unknown as MotionHighlightContextType<T>
|
||||
}
|
||||
|
||||
type BaseMotionHighlightProps<T extends string> = {
|
||||
mode?: MotionHighlightMode
|
||||
value?: T | null
|
||||
defaultValue?: T | null
|
||||
onValueChange?: (value: T | null) => void
|
||||
className?: string
|
||||
transition?: Transition
|
||||
hover?: boolean
|
||||
disabled?: boolean
|
||||
enabled?: boolean
|
||||
exitDelay?: number
|
||||
}
|
||||
|
||||
type ParentModeMotionHighlightProps = {
|
||||
boundsOffset?: Partial<Bounds>
|
||||
containerClassName?: string
|
||||
forceUpdateBounds?: boolean
|
||||
}
|
||||
|
||||
type ControlledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
|
||||
ParentModeMotionHighlightProps & {
|
||||
mode: 'parent'
|
||||
controlledItems: true
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type ControlledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
|
||||
mode?: 'children' | undefined
|
||||
controlledItems: true
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type UncontrolledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
|
||||
ParentModeMotionHighlightProps & {
|
||||
mode: 'parent'
|
||||
controlledItems?: false
|
||||
itemsClassName?: string
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
}
|
||||
|
||||
type UncontrolledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
|
||||
mode?: 'children'
|
||||
controlledItems?: false
|
||||
itemsClassName?: string
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
}
|
||||
|
||||
type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> &
|
||||
(
|
||||
| ControlledParentModeMotionHighlightProps<T>
|
||||
| ControlledChildrenModeMotionHighlightProps<T>
|
||||
| UncontrolledParentModeMotionHighlightProps<T>
|
||||
| UncontrolledChildrenModeMotionHighlightProps<T>
|
||||
)
|
||||
|
||||
function MotionHighlight<T extends string>({ ref, ...props }: MotionHighlightProps<T>) {
|
||||
const {
|
||||
children,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
className,
|
||||
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
||||
hover = false,
|
||||
enabled = true,
|
||||
controlledItems,
|
||||
disabled = false,
|
||||
exitDelay = 0.2,
|
||||
mode = 'children'
|
||||
} = props
|
||||
|
||||
const localRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
|
||||
|
||||
const [activeValue, setActiveValue] = React.useState<T | null>(value ?? defaultValue ?? null)
|
||||
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null)
|
||||
const [activeClassNameState, setActiveClassNameState] = React.useState<string>('')
|
||||
|
||||
const safeSetActiveValue = React.useCallback(
|
||||
(id: T | null) => {
|
||||
setActiveValue(prev => (prev === id ? prev : id))
|
||||
if (id !== activeValue) onValueChange?.(id as T)
|
||||
},
|
||||
[activeValue, onValueChange]
|
||||
)
|
||||
|
||||
const safeSetBounds = React.useCallback(
|
||||
(bounds: DOMRect) => {
|
||||
if (!localRef.current) return
|
||||
|
||||
const boundsOffset = (props as ParentModeMotionHighlightProps)?.boundsOffset ?? {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
|
||||
const containerRect = localRef.current.getBoundingClientRect()
|
||||
|
||||
const newBounds: Bounds = {
|
||||
top: bounds.top - containerRect.top + (boundsOffset.top ?? 0),
|
||||
left: bounds.left - containerRect.left + (boundsOffset.left ?? 0),
|
||||
width: bounds.width + (boundsOffset.width ?? 0),
|
||||
height: bounds.height + (boundsOffset.height ?? 0)
|
||||
}
|
||||
|
||||
setBoundsState(prev => {
|
||||
if (
|
||||
prev &&
|
||||
prev.top === newBounds.top &&
|
||||
prev.left === newBounds.left &&
|
||||
prev.width === newBounds.width &&
|
||||
prev.height === newBounds.height
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return newBounds
|
||||
})
|
||||
},
|
||||
[props]
|
||||
)
|
||||
|
||||
const clearBounds = React.useCallback(() => {
|
||||
setBoundsState(prev => (prev === null ? prev : null))
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== undefined) setActiveValue(value)
|
||||
else if (defaultValue !== undefined) setActiveValue(defaultValue)
|
||||
}, [value, defaultValue])
|
||||
|
||||
const id = React.useId()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'parent') return
|
||||
const container = localRef.current
|
||||
|
||||
if (!container) return
|
||||
|
||||
const onScroll = () => {
|
||||
if (!activeValue) return
|
||||
const activeEl = container.querySelector<HTMLElement>(`[data-value="${activeValue}"][data-highlight="true"]`)
|
||||
|
||||
if (activeEl) safeSetBounds(activeEl.getBoundingClientRect())
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
return () => container.removeEventListener('scroll', onScroll)
|
||||
}, [mode, activeValue, safeSetBounds])
|
||||
|
||||
const render = React.useCallback(
|
||||
(children: React.ReactNode) => {
|
||||
if (mode === 'parent') {
|
||||
return (
|
||||
<div
|
||||
ref={localRef}
|
||||
data-slot='motion-highlight-container'
|
||||
className={cn('relative', (props as ParentModeMotionHighlightProps)?.containerClassName)}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{boundsState && (
|
||||
<motion.div
|
||||
data-slot='motion-highlight'
|
||||
animate={{
|
||||
top: boundsState.top,
|
||||
left: boundsState.left,
|
||||
width: boundsState.width,
|
||||
height: boundsState.height,
|
||||
opacity: 1
|
||||
}}
|
||||
initial={{
|
||||
top: boundsState.top,
|
||||
left: boundsState.left,
|
||||
width: boundsState.width,
|
||||
height: boundsState.height,
|
||||
opacity: 0
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...transition,
|
||||
delay: (transition?.delay ?? 0) + (exitDelay ?? 0)
|
||||
}
|
||||
}}
|
||||
transition={transition}
|
||||
className={cn('bg-muted absolute z-0', className, activeClassNameState)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
},
|
||||
[mode, props, boundsState, transition, exitDelay, className, activeClassNameState]
|
||||
)
|
||||
|
||||
return (
|
||||
<MotionHighlightContext.Provider
|
||||
value={{
|
||||
mode,
|
||||
activeValue,
|
||||
setActiveValue: safeSetActiveValue,
|
||||
id,
|
||||
hover,
|
||||
className,
|
||||
transition,
|
||||
disabled,
|
||||
enabled,
|
||||
exitDelay,
|
||||
setBounds: safeSetBounds,
|
||||
clearBounds,
|
||||
activeClassName: activeClassNameState,
|
||||
setActiveClassName: setActiveClassNameState,
|
||||
forceUpdateBounds: (props as ParentModeMotionHighlightProps)?.forceUpdateBounds
|
||||
}}
|
||||
>
|
||||
{enabled
|
||||
? controlledItems
|
||||
? render(children)
|
||||
: render(
|
||||
React.Children.map(children, (child, index) => (
|
||||
<MotionHighlightItem key={index} className={props?.itemsClassName}>
|
||||
{child}
|
||||
</MotionHighlightItem>
|
||||
))
|
||||
)
|
||||
: children}
|
||||
</MotionHighlightContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function getNonOverridingDataAttributes(
|
||||
element: React.ReactElement,
|
||||
dataAttributes: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return Object.keys(dataAttributes).reduce<Record<string, unknown>>((acc, key) => {
|
||||
if ((element.props as Record<string, unknown>)[key] === undefined) {
|
||||
acc[key] = dataAttributes[key]
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
type ExtendedChildProps = React.ComponentProps<'div'> & {
|
||||
id?: string
|
||||
ref?: React.Ref<HTMLElement>
|
||||
'data-active'?: string
|
||||
'data-value'?: string
|
||||
'data-disabled'?: boolean
|
||||
'data-highlight'?: boolean
|
||||
'data-slot'?: string
|
||||
}
|
||||
|
||||
type MotionHighlightItemProps = React.ComponentProps<'div'> & {
|
||||
children: React.ReactElement
|
||||
id?: string
|
||||
value?: string
|
||||
className?: string
|
||||
transition?: Transition
|
||||
activeClassName?: string
|
||||
disabled?: boolean
|
||||
exitDelay?: number
|
||||
asChild?: boolean
|
||||
forceUpdateBounds?: boolean
|
||||
}
|
||||
|
||||
function MotionHighlightItem({
|
||||
ref,
|
||||
children,
|
||||
id,
|
||||
value,
|
||||
className,
|
||||
transition,
|
||||
disabled = false,
|
||||
activeClassName,
|
||||
exitDelay,
|
||||
asChild = false,
|
||||
forceUpdateBounds,
|
||||
...props
|
||||
}: MotionHighlightItemProps) {
|
||||
const itemId = React.useId()
|
||||
|
||||
const {
|
||||
activeValue,
|
||||
setActiveValue,
|
||||
mode,
|
||||
setBounds,
|
||||
clearBounds,
|
||||
hover,
|
||||
enabled,
|
||||
className: contextClassName,
|
||||
transition: contextTransition,
|
||||
id: contextId,
|
||||
disabled: contextDisabled,
|
||||
exitDelay: contextExitDelay,
|
||||
forceUpdateBounds: contextForceUpdateBounds,
|
||||
setActiveClassName
|
||||
} = useMotionHighlight()
|
||||
|
||||
const element = children as React.ReactElement<ExtendedChildProps>
|
||||
const childValue = id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId
|
||||
const isActive = activeValue === childValue
|
||||
const isDisabled = disabled === undefined ? contextDisabled : disabled
|
||||
const itemTransition = transition ?? contextTransition
|
||||
|
||||
const localRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'parent') return
|
||||
let rafId: number
|
||||
let previousBounds: Bounds | null = null
|
||||
const shouldUpdateBounds = forceUpdateBounds === true || (contextForceUpdateBounds && forceUpdateBounds !== false)
|
||||
|
||||
const updateBounds = () => {
|
||||
if (!localRef.current) return
|
||||
|
||||
const bounds = localRef.current.getBoundingClientRect()
|
||||
|
||||
if (shouldUpdateBounds) {
|
||||
if (
|
||||
previousBounds &&
|
||||
previousBounds.top === bounds.top &&
|
||||
previousBounds.left === bounds.left &&
|
||||
previousBounds.width === bounds.width &&
|
||||
previousBounds.height === bounds.height
|
||||
) {
|
||||
rafId = requestAnimationFrame(updateBounds)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
previousBounds = bounds
|
||||
rafId = requestAnimationFrame(updateBounds)
|
||||
}
|
||||
|
||||
setBounds(bounds)
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
updateBounds()
|
||||
setActiveClassName(activeClassName ?? '')
|
||||
} else if (!activeValue) clearBounds()
|
||||
|
||||
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId)
|
||||
}, [
|
||||
mode,
|
||||
isActive,
|
||||
activeValue,
|
||||
setBounds,
|
||||
clearBounds,
|
||||
activeClassName,
|
||||
setActiveClassName,
|
||||
forceUpdateBounds,
|
||||
contextForceUpdateBounds
|
||||
])
|
||||
|
||||
if (!React.isValidElement(children)) return children
|
||||
|
||||
const dataAttributes = {
|
||||
'data-active': isActive ? 'true' : 'false',
|
||||
'aria-selected': isActive,
|
||||
'data-disabled': isDisabled,
|
||||
'data-value': childValue,
|
||||
'data-highlight': true
|
||||
}
|
||||
|
||||
const commonHandlers = hover
|
||||
? {
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(childValue)
|
||||
element.props.onMouseEnter?.(e)
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(null)
|
||||
element.props.onMouseLeave?.(e)
|
||||
}
|
||||
}
|
||||
: {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(childValue)
|
||||
element.props.onClick?.(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (asChild) {
|
||||
if (mode === 'children') {
|
||||
return React.cloneElement(
|
||||
element,
|
||||
{
|
||||
key: childValue,
|
||||
ref: localRef,
|
||||
className: cn('relative', element.props.className),
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item-container'
|
||||
}),
|
||||
...commonHandlers,
|
||||
...props
|
||||
},
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot='motion-highlight'
|
||||
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
|
||||
}
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div data-slot='motion-highlight-item' className={cn('relative z-[1]', className)} {...dataAttributes}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return React.cloneElement(element, {
|
||||
ref: localRef,
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item'
|
||||
}),
|
||||
...commonHandlers
|
||||
})
|
||||
}
|
||||
|
||||
return enabled ? (
|
||||
<div
|
||||
key={childValue}
|
||||
ref={localRef}
|
||||
data-slot='motion-highlight-item-container'
|
||||
className={cn(mode === 'children' && 'relative', className)}
|
||||
{...dataAttributes}
|
||||
{...props}
|
||||
{...commonHandlers}
|
||||
>
|
||||
{mode === 'children' && (
|
||||
<AnimatePresence initial={false}>
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot='motion-highlight'
|
||||
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
|
||||
}
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{React.cloneElement(element, {
|
||||
className: cn('relative z-[1]', element.props.className),
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item'
|
||||
})
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
MotionHighlight,
|
||||
MotionHighlightItem,
|
||||
useMotionHighlight,
|
||||
type MotionHighlightProps,
|
||||
type MotionHighlightItemProps
|
||||
}
|
||||
261
src/components/ui/motion-tabs.tsx
Normal file
261
src/components/ui/motion-tabs.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { motion, type Transition, type HTMLMotionProps } from 'motion/react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MotionHighlight, MotionHighlightItem } from '@/components/ui/motion-highlight'
|
||||
|
||||
type TabsContextType<T extends string> = {
|
||||
activeValue: T
|
||||
handleValueChange: (value: T) => void
|
||||
registerTrigger: (value: T, node: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const TabsContext = React.createContext<TabsContextType<any> | undefined>(undefined)
|
||||
|
||||
function useTabs<T extends string = string>(): TabsContextType<T> {
|
||||
const context = React.useContext(TabsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTabs must be used within a TabsProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
type BaseTabsProps = React.ComponentProps<'div'> & {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type UnControlledTabsProps<T extends string = string> = BaseTabsProps & {
|
||||
defaultValue?: T
|
||||
value?: never
|
||||
onValueChange?: never
|
||||
}
|
||||
|
||||
type ControlledTabsProps<T extends string = string> = BaseTabsProps & {
|
||||
value: T
|
||||
onValueChange?: (value: T) => void
|
||||
defaultValue?: never
|
||||
}
|
||||
|
||||
type TabsProps<T extends string = string> = UnControlledTabsProps<T> | ControlledTabsProps<T>
|
||||
|
||||
function Tabs<T extends string = string>({
|
||||
defaultValue,
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TabsProps<T>) {
|
||||
const [activeValue, setActiveValue] = React.useState<T | undefined>(defaultValue ?? undefined)
|
||||
const triggersRef = React.useRef(new Map<string, HTMLElement>())
|
||||
const initialSet = React.useRef(false)
|
||||
const isControlled = value !== undefined
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isControlled && activeValue === undefined && triggersRef.current.size > 0 && !initialSet.current) {
|
||||
const firstTab = Array.from(triggersRef.current.keys())[0]
|
||||
|
||||
setActiveValue(firstTab as T)
|
||||
initialSet.current = true
|
||||
}
|
||||
}, [activeValue, isControlled])
|
||||
|
||||
const registerTrigger = (value: string, node: HTMLElement | null) => {
|
||||
if (node) {
|
||||
triggersRef.current.set(value, node)
|
||||
|
||||
if (!isControlled && activeValue === undefined && !initialSet.current) {
|
||||
setActiveValue(value as T)
|
||||
initialSet.current = true
|
||||
}
|
||||
} else {
|
||||
triggersRef.current.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValueChange = (val: T) => {
|
||||
if (!isControlled) setActiveValue(val)
|
||||
else onValueChange?.(val)
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsContext.Provider
|
||||
value={{
|
||||
activeValue: (value ?? activeValue)!,
|
||||
handleValueChange,
|
||||
registerTrigger
|
||||
}}
|
||||
>
|
||||
<div data-slot='tabs' className={cn('flex flex-col gap-2', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsListProps = React.ComponentProps<'div'> & {
|
||||
children: React.ReactNode
|
||||
activeClassName?: string
|
||||
transition?: Transition
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
children,
|
||||
className,
|
||||
activeClassName,
|
||||
transition = {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 25
|
||||
},
|
||||
...props
|
||||
}: TabsListProps) {
|
||||
const { activeValue } = useTabs()
|
||||
|
||||
return (
|
||||
<MotionHighlight
|
||||
controlledItems
|
||||
className={cn('bg-background rounded-sm shadow-sm', activeClassName)}
|
||||
value={activeValue}
|
||||
transition={transition}
|
||||
>
|
||||
<div
|
||||
role='tablist'
|
||||
data-slot='tabs-list'
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg p-[4px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</MotionHighlight>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsTriggerProps = HTMLMotionProps<'button'> & {
|
||||
value: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function TabsTrigger({ ref, value, children, className, ...props }: TabsTriggerProps) {
|
||||
const { activeValue, handleValueChange, registerTrigger } = useTabs()
|
||||
|
||||
const localRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLButtonElement)
|
||||
|
||||
React.useEffect(() => {
|
||||
registerTrigger(value, localRef.current)
|
||||
|
||||
return () => registerTrigger(value, null)
|
||||
}, [value, registerTrigger])
|
||||
|
||||
return (
|
||||
<MotionHighlightItem value={value} className='size-full'>
|
||||
<motion.button
|
||||
ref={localRef}
|
||||
data-slot='tabs-trigger'
|
||||
role='tab'
|
||||
onClick={() => handleValueChange(value)}
|
||||
data-state={activeValue === value ? 'active' : 'inactive'}
|
||||
className={cn(
|
||||
'ring-offset-background focus-visible:ring-ring data-[state=active]:text-foreground z-[1] inline-flex size-full cursor-pointer items-center justify-center rounded-sm px-2 py-1 text-sm font-medium whitespace-nowrap transition-transform focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
</MotionHighlightItem>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsContentsProps = React.ComponentProps<'div'> & {
|
||||
children: React.ReactNode
|
||||
transition?: Transition
|
||||
}
|
||||
|
||||
function TabsContents({
|
||||
children,
|
||||
className,
|
||||
transition = {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
bounce: 0,
|
||||
restDelta: 0.01
|
||||
},
|
||||
...props
|
||||
}: TabsContentsProps) {
|
||||
const { activeValue } = useTabs()
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
|
||||
const activeIndex = childrenArray.findIndex(
|
||||
(child): child is React.ReactElement<{ value: string }> =>
|
||||
React.isValidElement(child) &&
|
||||
typeof child.props === 'object' &&
|
||||
child.props !== null &&
|
||||
'value' in child.props &&
|
||||
child.props.value === activeValue
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-slot='tabs-contents' className={cn('overflow-hidden', className)} {...props}>
|
||||
<motion.div className='-mx-2 flex' animate={{ x: activeIndex * -100 + '%' }} transition={transition}>
|
||||
{childrenArray.map((child, index) => (
|
||||
<div key={index} className='w-full shrink-0 px-2'>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsContentProps = HTMLMotionProps<'div'> & {
|
||||
value: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function TabsContent({ children, value, className, ...props }: TabsContentProps) {
|
||||
const { activeValue } = useTabs()
|
||||
const isActive = activeValue === value
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
role='tabpanel'
|
||||
data-slot='tabs-content'
|
||||
className={cn('overflow-hidden', className)}
|
||||
initial={{ filter: 'blur(0px)' }}
|
||||
animate={{ filter: isActive ? 'blur(0px)' : 'blur(2px)' }}
|
||||
exit={{ filter: 'blur(0px)' }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContents,
|
||||
TabsContent,
|
||||
useTabs,
|
||||
type TabsContextType,
|
||||
type TabsProps,
|
||||
type TabsListProps,
|
||||
type TabsTriggerProps,
|
||||
type TabsContentsProps,
|
||||
type TabsContentProps
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { buildRange, throwIfError, requireData } from "./_helpers";
|
||||
|
||||
import { buildRange, requireData, throwIfError } from "./_helpers";
|
||||
|
||||
import type {
|
||||
Asignatura,
|
||||
CambioPlan,
|
||||
@@ -18,6 +20,7 @@ const EDGE = {
|
||||
ai_generate_plan: "ai_generate_plan",
|
||||
plans_persist_from_ai: "plans_persist_from_ai",
|
||||
plans_clone_from_existing: "plans_clone_from_existing",
|
||||
|
||||
plans_import_from_files: "plans_import_from_files",
|
||||
|
||||
plans_update_fields: "plans_update_fields",
|
||||
@@ -39,23 +42,33 @@ export type PlanListFilters = {
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function plans_list(filters: PlanListFilters = {}): Promise<Paged<PlanEstudio>> {
|
||||
export async function plans_list(
|
||||
filters: PlanListFilters = {},
|
||||
): Promise<Paged<PlanEstudio>> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
// 1. Construimos la query.
|
||||
// TypeScript validará que "planes_estudio" existe en Database
|
||||
let q = supabase
|
||||
.from("planes_estudio")
|
||||
.select(
|
||||
`
|
||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)),
|
||||
estructuras_plan(id,nombre,tipo,version,definicion),
|
||||
estados_plan(id,clave,etiqueta,orden,es_final)
|
||||
*,
|
||||
carreras (
|
||||
*,
|
||||
facultades (*)
|
||||
),
|
||||
estructuras_plan (*),
|
||||
estados_plan (*)
|
||||
`,
|
||||
{ count: "exact" }
|
||||
{ count: "exact" },
|
||||
)
|
||||
.order("actualizado_en", { ascending: false });
|
||||
|
||||
if (filters.search?.trim()) q = q.ilike("nombre", `%${filters.search.trim()}%`);
|
||||
// 2. Aplicamos filtros dinámicos
|
||||
if (filters.search?.trim()) {
|
||||
q = q.ilike("nombre", `%${filters.search.trim()}%`);
|
||||
}
|
||||
if (filters.carreraId) q = q.eq("carrera_id", filters.carreraId);
|
||||
if (filters.estadoId) q = q.eq("estado_actual_id", filters.estadoId);
|
||||
if (typeof filters.activo === "boolean") q = q.eq("activo", filters.activo);
|
||||
@@ -63,13 +76,19 @@ export async function plans_list(filters: PlanListFilters = {}): Promise<Paged<P
|
||||
// filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos)
|
||||
if (filters.facultadId) q = q.eq("carreras.facultad_id", filters.facultadId);
|
||||
|
||||
// 3. Paginación
|
||||
const { from, to } = buildRange(filters.limit, filters.offset);
|
||||
if (typeof from === "number" && typeof to === "number") q = q.range(from, to);
|
||||
if (from !== undefined && to !== undefined) q = q.range(from, to);
|
||||
|
||||
const { data, error, count } = await q;
|
||||
throwIfError(error);
|
||||
|
||||
return { data: data ?? [], count: count ?? null };
|
||||
return {
|
||||
// 1. Si data es null, usa [].
|
||||
// 2. Luego dile a TS que el resultado es tu Array tipado.
|
||||
data: (data ?? []) as unknown as Array<PlanEstudio>,
|
||||
count: count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||
@@ -79,11 +98,11 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||
.from("planes_estudio")
|
||||
.select(
|
||||
`
|
||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)),
|
||||
estructuras_plan(id,nombre,tipo,version,definicion),
|
||||
estados_plan(id,clave,etiqueta,orden,es_final)
|
||||
`
|
||||
*,
|
||||
carreras (*, facultades(*)),
|
||||
estructuras_plan (*),
|
||||
estados_plan (*)
|
||||
`,
|
||||
)
|
||||
.eq("id", planId)
|
||||
.single();
|
||||
@@ -92,7 +111,9 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||
return requireData(data, "Plan no encontrado.");
|
||||
}
|
||||
|
||||
export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> {
|
||||
export async function plan_lineas_list(
|
||||
planId: UUID,
|
||||
): Promise<Array<LineaPlan>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("lineas_plan")
|
||||
@@ -104,12 +125,14 @@ export async function plan_lineas_list(planId: UUID): Promise<LineaPlan[]> {
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function plan_asignaturas_list(planId: UUID): Promise<Asignatura[]> {
|
||||
export async function plan_asignaturas_list(
|
||||
planId: UUID,
|
||||
): Promise<Array<Asignatura>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.select(
|
||||
"id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en"
|
||||
"id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en",
|
||||
)
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("numero_ciclo", { ascending: true, nullsFirst: false })
|
||||
@@ -120,11 +143,13 @@ export async function plan_asignaturas_list(planId: UUID): Promise<Asignatura[]>
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export async function plans_history(planId: UUID): Promise<CambioPlan[]> {
|
||||
export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("cambios_plan")
|
||||
.select("id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id")
|
||||
.select(
|
||||
"id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id",
|
||||
)
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("cambiado_en", { ascending: false });
|
||||
|
||||
@@ -143,7 +168,9 @@ export type PlansCreateManualInput = {
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
|
||||
export async function plans_create_manual(input: PlansCreateManualInput): Promise<PlanEstudio> {
|
||||
export async function plans_create_manual(
|
||||
input: PlansCreateManualInput,
|
||||
): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input);
|
||||
}
|
||||
|
||||
@@ -161,23 +188,31 @@ export type AIGeneratePlanInput = {
|
||||
descripcionEnfoque: string;
|
||||
poblacionObjetivo?: string;
|
||||
notasAdicionales?: string;
|
||||
archivosReferencia?: UUID[];
|
||||
repositoriosIds?: UUID[];
|
||||
archivosReferencia?: Array<UUID>;
|
||||
repositoriosIds?: Array<UUID>;
|
||||
usarMCP?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function ai_generate_plan(input: AIGeneratePlanInput): Promise<any> {
|
||||
export async function ai_generate_plan(
|
||||
input: AIGeneratePlanInput,
|
||||
): Promise<any> {
|
||||
return invokeEdge<any>(EDGE.ai_generate_plan, input);
|
||||
}
|
||||
|
||||
export async function plans_persist_from_ai(payload: { jsonPlan: any }): Promise<PlanEstudio> {
|
||||
export async function plans_persist_from_ai(
|
||||
payload: { jsonPlan: any },
|
||||
): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload);
|
||||
}
|
||||
|
||||
export async function plans_clone_from_existing(payload: {
|
||||
planOrigenId: UUID;
|
||||
overrides: Partial<Pick<PlanEstudio, "nombre" | "nivel" | "tipo_ciclo" | "numero_ciclos">> & {
|
||||
overrides:
|
||||
& Partial<
|
||||
Pick<PlanEstudio, "nombre" | "nivel" | "tipo_ciclo" | "numero_ciclos">
|
||||
>
|
||||
& {
|
||||
carrera_id?: UUID;
|
||||
estructura_id?: UUID;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
@@ -211,7 +246,10 @@ export type PlansUpdateFieldsPatch = {
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
|
||||
export async function plans_update_fields(planId: UUID, patch: PlansUpdateFieldsPatch): Promise<PlanEstudio> {
|
||||
export async function plans_update_fields(
|
||||
planId: UUID,
|
||||
patch: PlansUpdateFieldsPatch,
|
||||
): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch });
|
||||
}
|
||||
|
||||
@@ -228,10 +266,13 @@ export type PlanMapOperation =
|
||||
op: "REORDER_CELDA";
|
||||
linea_plan_id: UUID;
|
||||
numero_ciclo: number;
|
||||
asignaturaIdsOrdenados: UUID[];
|
||||
asignaturaIdsOrdenados: Array<UUID>;
|
||||
};
|
||||
|
||||
export async function plans_update_map(planId: UUID, ops: PlanMapOperation[]): Promise<{ ok: true }> {
|
||||
export async function plans_update_map(
|
||||
planId: UUID,
|
||||
ops: Array<PlanMapOperation>,
|
||||
): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops });
|
||||
}
|
||||
|
||||
@@ -251,10 +292,16 @@ export type DocumentoResult = {
|
||||
nombre?: string;
|
||||
};
|
||||
|
||||
export async function plans_generate_document(planId: UUID): Promise<DocumentoResult> {
|
||||
export async function plans_generate_document(
|
||||
planId: UUID,
|
||||
): Promise<DocumentoResult> {
|
||||
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId });
|
||||
}
|
||||
|
||||
export async function plans_get_document(planId: UUID): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, { planId });
|
||||
export async function plans_get_document(
|
||||
planId: UUID,
|
||||
): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, {
|
||||
planId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
ai_plan_chat,
|
||||
ai_plan_improve,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import type { PlanEstudio, UUID } from "../types/domain";
|
||||
import type { PlanListFilters, PlanMapOperation, PlansCreateManualInput, PlansUpdateFieldsPatch } from "../api/plans.api";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
ai_generate_plan,
|
||||
plan_asignaturas_list,
|
||||
@@ -19,13 +22,30 @@ import {
|
||||
plans_update_fields,
|
||||
plans_update_map,
|
||||
} from "../api/plans.api";
|
||||
import { qk } from "../query/keys";
|
||||
|
||||
import type {
|
||||
PlanListFilters,
|
||||
PlanMapOperation,
|
||||
PlansCreateManualInput,
|
||||
PlansUpdateFieldsPatch,
|
||||
} from "../api/plans.api";
|
||||
import type { UUID } from "../types/domain";
|
||||
|
||||
export function usePlanes(filters: PlanListFilters) {
|
||||
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
|
||||
return useQuery({
|
||||
// Usamos la factory de keys para consistencia
|
||||
queryKey: qk.planesList(filters),
|
||||
|
||||
// La función fetch
|
||||
queryFn: () => plans_list(filters),
|
||||
|
||||
// UX: Mantiene los datos viejos mientras carga la paginación nueva
|
||||
placeholderData: keepPreviousData,
|
||||
|
||||
// Opcional: Tiempo que la data se considera fresca
|
||||
staleTime: 1000 * 60 * 5, // 5 minutos
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,7 +67,9 @@ export function usePlanLineas(planId: UUID | null | undefined) {
|
||||
|
||||
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planAsignaturas(planId) : ["planes", "asignaturas", null],
|
||||
queryKey: planId
|
||||
? qk.planAsignaturas(planId)
|
||||
: ["planes", "asignaturas", null],
|
||||
queryFn: () => plan_asignaturas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
@@ -144,7 +166,8 @@ export function useUpdatePlanMapa() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { planId: UUID; ops: PlanMapOperation[] }) => plans_update_map(vars.planId, vars.ops),
|
||||
mutationFn: (vars: { planId: UUID; ops: Array<PlanMapOperation> }) =>
|
||||
plans_update_map(vars.planId, vars.ops),
|
||||
|
||||
// ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA
|
||||
onMutate: async (vars) => {
|
||||
@@ -152,9 +175,7 @@ export function useUpdatePlanMapa() {
|
||||
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId));
|
||||
|
||||
// solo optimizamos MOVEs simples
|
||||
const moves = vars.ops.filter((x) => x.op === "MOVE_ASIGNATURA") as Array<
|
||||
Extract<PlanMapOperation, { op: "MOVE_ASIGNATURA" }>
|
||||
>;
|
||||
const moves = vars.ops.filter((x) => x.op === "MOVE_ASIGNATURA");
|
||||
|
||||
if (prev && Array.isArray(prev) && moves.length) {
|
||||
const next = prev.map((a: any) => {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (failureCount) => failureCount < 2,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,20 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
export function getContext() {
|
||||
const queryClient = new QueryClient()
|
||||
const queryClient = new QueryClient(
|
||||
{
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (failureCount) => failureCount < 2,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
queryClient,
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../types/database";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
import { getEnv } from "./env";
|
||||
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "src/types/supabase.js";
|
||||
|
||||
let _client: SupabaseClient<Database> | null = null;
|
||||
|
||||
export function supabaseBrowser(): SupabaseClient<Database> {
|
||||
@@ -10,13 +13,13 @@ export function supabaseBrowser(): SupabaseClient<Database> {
|
||||
const url = getEnv(
|
||||
"VITE_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"SUPABASE_URL"
|
||||
"SUPABASE_URL",
|
||||
);
|
||||
|
||||
const anonKey = getEnv(
|
||||
"VITE_SUPABASE_ANON_KEY",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"SUPABASE_ANON_KEY"
|
||||
"SUPABASE_ANON_KEY",
|
||||
);
|
||||
|
||||
_client = createClient<Database>(url, anonKey, {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json }
|
||||
| Json[];
|
||||
|
||||
export type Database = any; // ✅ Reemplaza por tipos generados (supabase gen types typescript)
|
||||
@@ -1,29 +1,22 @@
|
||||
import type { Json } from "./database";
|
||||
import type { Enums, Tables } from "../../types/supabase";
|
||||
|
||||
export type UUID = string;
|
||||
|
||||
export type TipoEstructuraPlan = "CURRICULAR" | "NO_CURRICULAR";
|
||||
export type NivelPlanEstudio =
|
||||
| "LICENCIATURA"
|
||||
| "MAESTRIA"
|
||||
| "DOCTORADO"
|
||||
| "ESPECIALIDAD"
|
||||
| "DIPLOMADO"
|
||||
| "OTRO";
|
||||
export type TipoEstructuraPlan = Enums<"tipo_estructura_plan">;
|
||||
export type NivelPlanEstudio = Enums<"nivel_plan_estudio">;
|
||||
export type TipoCiclo = Enums<"tipo_ciclo">;
|
||||
|
||||
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE" | "OTRO";
|
||||
export type TipoOrigen = Enums<"tipo_origen">;
|
||||
|
||||
export type TipoOrigen = "MANUAL" | "IA" | "CLONADO_INTERNO" | "TRADICIONAL" | "OTRO";
|
||||
export type TipoAsignatura = Enums<"tipo_asignatura">;
|
||||
|
||||
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRA";
|
||||
export type TipoBibliografia = Enums<"tipo_bibliografia">;
|
||||
export type TipoFuenteBibliografia = Enums<"tipo_fuente_bibliografia">;
|
||||
|
||||
export type TipoBibliografia = "BASICA" | "COMPLEMENTARIA";
|
||||
export type TipoFuenteBibliografia = "MANUAL" | "BIBLIOTECA";
|
||||
export type EstadoTareaRevision = Enums<"estado_tarea_revision">;
|
||||
export type TipoNotificacion = Enums<"tipo_notificacion">;
|
||||
|
||||
export type EstadoTareaRevision = "PENDIENTE" | "COMPLETADA" | "OMITIDA";
|
||||
export type TipoNotificacion = "PLAN_ASIGNADO" | "ESTADO_CAMBIADO" | "TAREA_ASIGNADA" | "COMENTARIO" | "OTRA";
|
||||
|
||||
export type TipoInteraccionIA = "GENERAR" | "MEJORAR_SECCION" | "CHAT" | "OTRA";
|
||||
export type TipoInteraccionIA = Enums<"tipo_interaccion_ia">;
|
||||
|
||||
export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta";
|
||||
export type DisenoCurricular = "Rígido" | "Flexible";
|
||||
@@ -58,218 +51,38 @@ export type PlanDatosSep = {
|
||||
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
|
||||
};
|
||||
|
||||
export type Paged<T> = { data: T[]; count: number | null };
|
||||
export type Paged<T> = { data: Array<T>; count: number | null };
|
||||
|
||||
export type Facultad = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
nombre_corto: string | null;
|
||||
color: string | null;
|
||||
icono: string | null;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
export type FacultadRow = Tables<"facultades">;
|
||||
export type CarreraRow = Tables<"carreras">;
|
||||
|
||||
export type EstructuraPlanRow = Tables<"estructuras_plan">;
|
||||
|
||||
export type EstructuraAsignatura = Tables<"estructuras_asignatura">;
|
||||
|
||||
export type EstadoPlanRow = Tables<"estados_plan">;
|
||||
export type PlanEstudioRow = Tables<"planes_estudio">;
|
||||
|
||||
export type PlanEstudio = PlanEstudioRow & {
|
||||
carreras: (CarreraRow & { facultades: FacultadRow | null }) | null;
|
||||
estructuras_plan: EstructuraPlanRow | null;
|
||||
estados_plan: EstadoPlanRow | null;
|
||||
};
|
||||
|
||||
export type Carrera = {
|
||||
id: UUID;
|
||||
facultad_id: UUID;
|
||||
nombre: string;
|
||||
nombre_corto: string | null;
|
||||
clave_sep: string | null;
|
||||
activa: boolean;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
export type LineaPlan = Tables<"lineas_plan">;
|
||||
|
||||
facultades?: Facultad | null;
|
||||
};
|
||||
export type Asignatura = Tables<"asignaturas">;
|
||||
|
||||
export type EstructuraPlan = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
tipo: TipoEstructuraPlan;
|
||||
version: string | null;
|
||||
definicion: Json;
|
||||
};
|
||||
export type BibliografiaAsignatura = Tables<"bibliografia_asignatura">;
|
||||
|
||||
export type EstructuraAsignatura = {
|
||||
id: UUID;
|
||||
nombre: string;
|
||||
version: string | null;
|
||||
definicion: Json;
|
||||
};
|
||||
export type CambioPlan = Tables<"cambios_plan">;
|
||||
|
||||
export type EstadoPlan = {
|
||||
id: UUID;
|
||||
clave: string;
|
||||
etiqueta: string;
|
||||
orden: number;
|
||||
es_final: boolean;
|
||||
};
|
||||
export type CambioAsignatura = Tables<"cambios_asignatura">;
|
||||
|
||||
export type PlanEstudio = {
|
||||
id: UUID;
|
||||
carrera_id: UUID;
|
||||
estructura_id: UUID;
|
||||
export type InteraccionIA = Tables<"interacciones_ia">;
|
||||
|
||||
nombre: string;
|
||||
nivel: NivelPlanEstudio;
|
||||
tipo_ciclo: TipoCiclo;
|
||||
numero_ciclos: number;
|
||||
export type TareaRevision = Tables<"tareas_revision">;
|
||||
|
||||
datos: Json;
|
||||
export type Notificacion = Tables<"notificaciones">;
|
||||
|
||||
estado_actual_id: UUID | null;
|
||||
activo: boolean;
|
||||
|
||||
tipo_origen: TipoOrigen | null;
|
||||
meta_origen: Json;
|
||||
|
||||
creado_por: UUID | null;
|
||||
actualizado_por: UUID | null;
|
||||
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
|
||||
carreras?: Carrera | null;
|
||||
estructuras_plan?: EstructuraPlan | null;
|
||||
estados_plan?: EstadoPlan | null;
|
||||
};
|
||||
|
||||
export type LineaPlan = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
nombre: string;
|
||||
orden: number;
|
||||
area: string | null;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
};
|
||||
|
||||
export type Asignatura = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
estructura_id: UUID | null;
|
||||
|
||||
facultad_propietaria_id: UUID | null;
|
||||
|
||||
codigo: string | null;
|
||||
nombre: string;
|
||||
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horas_semana: number | null;
|
||||
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
orden_celda: number | null;
|
||||
|
||||
datos: Json;
|
||||
contenido_tematico: Json;
|
||||
|
||||
tipo_origen: TipoOrigen | null;
|
||||
meta_origen: Json;
|
||||
|
||||
creado_por: UUID | null;
|
||||
actualizado_por: UUID | null;
|
||||
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
|
||||
planes_estudio?: PlanEstudio | null;
|
||||
estructuras_asignatura?: EstructuraAsignatura | null;
|
||||
};
|
||||
|
||||
export type BibliografiaAsignatura = {
|
||||
id: UUID;
|
||||
asignatura_id: UUID;
|
||||
tipo: TipoBibliografia;
|
||||
cita: string;
|
||||
tipo_fuente: TipoFuenteBibliografia;
|
||||
biblioteca_item_id: string | null;
|
||||
|
||||
creado_por: UUID | null;
|
||||
creado_en: string;
|
||||
actualizado_en: string;
|
||||
};
|
||||
|
||||
export type CambioPlan = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
cambiado_por: UUID | null;
|
||||
cambiado_en: string;
|
||||
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
|
||||
campo: string | null;
|
||||
valor_anterior: Json | null;
|
||||
valor_nuevo: Json | null;
|
||||
interaccion_ia_id: UUID | null;
|
||||
};
|
||||
|
||||
export type CambioAsignatura = {
|
||||
id: UUID;
|
||||
asignatura_id: UUID;
|
||||
cambiado_por: UUID | null;
|
||||
cambiado_en: string;
|
||||
tipo: "ACTUALIZACION_CAMPO" | "ACTUALIZACION_MAPA" | "OTRO";
|
||||
campo: string | null;
|
||||
valor_anterior: Json | null;
|
||||
valor_nuevo: Json | null;
|
||||
fuente: "HUMANO" | "IA" | null;
|
||||
interaccion_ia_id: UUID | null;
|
||||
};
|
||||
|
||||
export type InteraccionIA = {
|
||||
id: UUID;
|
||||
usuario_id: UUID | null;
|
||||
plan_estudio_id: UUID | null;
|
||||
asignatura_id: UUID | null;
|
||||
|
||||
tipo: TipoInteraccionIA;
|
||||
modelo: string | null;
|
||||
temperatura: number | null;
|
||||
|
||||
prompt: Json;
|
||||
respuesta: Json;
|
||||
|
||||
aceptada: boolean;
|
||||
|
||||
conversacion_id: string | null;
|
||||
ids_archivos: Json;
|
||||
ids_vector_store: Json;
|
||||
|
||||
creado_en: string;
|
||||
};
|
||||
|
||||
export type TareaRevision = {
|
||||
id: UUID;
|
||||
plan_estudio_id: UUID;
|
||||
asignado_a: UUID;
|
||||
rol_id: UUID | null;
|
||||
estado_id: UUID | null;
|
||||
estatus: EstadoTareaRevision;
|
||||
fecha_limite: string | null;
|
||||
creado_en: string;
|
||||
completado_en: string | null;
|
||||
};
|
||||
|
||||
export type Notificacion = {
|
||||
id: UUID;
|
||||
usuario_id: UUID;
|
||||
tipo: TipoNotificacion;
|
||||
payload: Json;
|
||||
leida: boolean;
|
||||
creado_en: string;
|
||||
leida_en: string | null;
|
||||
};
|
||||
|
||||
export type Archivo = {
|
||||
id: UUID;
|
||||
ruta_storage: string;
|
||||
nombre: string;
|
||||
mime_type: string | null;
|
||||
bytes: number | null;
|
||||
subido_por: UUID | null;
|
||||
subido_en: string;
|
||||
temporal: boolean;
|
||||
openai_file_id: string | null;
|
||||
notas: string | null;
|
||||
};
|
||||
export type Archivo = Tables<"archivos">;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { TipoCiclo } from "./types";
|
||||
|
||||
export const FACULTADES = [
|
||||
{ id: "ing", nombre: "Facultad de Ingeniería" },
|
||||
{
|
||||
id: "med",
|
||||
nombre: "Facultad de Medicina en medicina en medicina en medicina",
|
||||
},
|
||||
{ id: "neg", nombre: "Facultad de Negocios" },
|
||||
];
|
||||
|
||||
export const CARRERAS = [
|
||||
{ id: "sis", nombre: "Ing. en Sistemas", facultadId: "ing" },
|
||||
{ id: "ind", nombre: "Ing. Industrial", facultadId: "ing" },
|
||||
{ id: "medico", nombre: "Médico Cirujano", facultadId: "med" },
|
||||
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
|
||||
];
|
||||
|
||||
export const NIVELES = [
|
||||
"Licenciatura",
|
||||
"Especialidad",
|
||||
"Maestría",
|
||||
"Doctorado",
|
||||
];
|
||||
export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [
|
||||
{ value: "SEMESTRE", label: "Semestre" },
|
||||
{ value: "CUATRIMESTRE", label: "Cuatrimestre" },
|
||||
{ value: "TRIMESTRE", label: "Trimestre" },
|
||||
];
|
||||
|
||||
export const PLANES_EXISTENTES = [
|
||||
{
|
||||
id: "plan-2021-sis",
|
||||
nombre: "ISC 2021",
|
||||
estado: "Aprobado",
|
||||
anio: 2021,
|
||||
facultadId: "ing",
|
||||
carreraId: "sis",
|
||||
},
|
||||
{
|
||||
id: "plan-2020-ind",
|
||||
nombre: "I. Industrial 2020",
|
||||
estado: "Aprobado",
|
||||
anio: 2020,
|
||||
facultadId: "ing",
|
||||
carreraId: "ind",
|
||||
},
|
||||
{
|
||||
id: "plan-2019-med",
|
||||
nombre: "Medicina 2019",
|
||||
estado: "Vigente",
|
||||
anio: 2019,
|
||||
facultadId: "med",
|
||||
carreraId: "medico",
|
||||
},
|
||||
];
|
||||
@@ -3,8 +3,8 @@ import * as Icons from 'lucide-react'
|
||||
|
||||
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
|
||||
|
||||
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm'
|
||||
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel'
|
||||
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel'
|
||||
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
|
||||
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
|
||||
import { WizardControls } from '@/components/planes/wizard/WizardControls'
|
||||
152
src/features/planes/nuevo/catalogs.ts
Normal file
152
src/features/planes/nuevo/catalogs.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { TipoCiclo } from "./types";
|
||||
|
||||
export const FACULTADES = [
|
||||
{ id: "ing", nombre: "Facultad de Ingeniería" },
|
||||
{
|
||||
id: "med",
|
||||
nombre: "Facultad de Medicina en medicina en medicina en medicina",
|
||||
},
|
||||
{ id: "neg", nombre: "Facultad de Negocios" },
|
||||
];
|
||||
|
||||
export const CARRERAS = [
|
||||
{ id: "sis", nombre: "Ing. en Sistemas", facultadId: "ing" },
|
||||
{ id: "ind", nombre: "Ing. Industrial", facultadId: "ing" },
|
||||
{ id: "medico", nombre: "Médico Cirujano", facultadId: "med" },
|
||||
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
|
||||
];
|
||||
|
||||
export const NIVELES = [
|
||||
"Licenciatura",
|
||||
"Especialidad",
|
||||
"Maestría",
|
||||
"Doctorado",
|
||||
];
|
||||
export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [
|
||||
{ value: "SEMESTRE", label: "Semestre" },
|
||||
{ value: "CUATRIMESTRE", label: "Cuatrimestre" },
|
||||
{ value: "TRIMESTRE", label: "Trimestre" },
|
||||
];
|
||||
|
||||
export const PLANES_EXISTENTES = [
|
||||
{
|
||||
id: "plan-2021-sis",
|
||||
nombre: "ISC 2021",
|
||||
estado: "Aprobado",
|
||||
anio: 2021,
|
||||
facultadId: "ing",
|
||||
carreraId: "sis",
|
||||
},
|
||||
{
|
||||
id: "plan-2020-ind",
|
||||
nombre: "I. Industrial 2020",
|
||||
estado: "Aprobado",
|
||||
anio: 2020,
|
||||
facultadId: "ing",
|
||||
carreraId: "ind",
|
||||
},
|
||||
{
|
||||
id: "plan-2019-med",
|
||||
nombre: "Medicina 2019",
|
||||
estado: "Vigente",
|
||||
anio: 2019,
|
||||
facultadId: "med",
|
||||
carreraId: "medico",
|
||||
},
|
||||
];
|
||||
|
||||
export const ARCHIVOS = [
|
||||
{
|
||||
id: "file-1",
|
||||
nombre: "Sílabo POO 2023.docx",
|
||||
tipo: "docx",
|
||||
tamaño: "245 KB",
|
||||
},
|
||||
{
|
||||
id: "file-2",
|
||||
nombre: "Guía de prácticas BD.pdf",
|
||||
tipo: "pdf",
|
||||
tamaño: "1.2 MB",
|
||||
},
|
||||
{
|
||||
id: "file-3",
|
||||
nombre: "Rúbrica evaluación proyectos.xlsx",
|
||||
tipo: "xlsx",
|
||||
tamaño: "89 KB",
|
||||
},
|
||||
{
|
||||
id: "file-4",
|
||||
nombre: "Banco de reactivos IA.docx",
|
||||
tipo: "docx",
|
||||
tamaño: "567 KB",
|
||||
},
|
||||
{
|
||||
id: "file-5",
|
||||
nombre: "Material didáctico Web.pdf",
|
||||
tipo: "pdf",
|
||||
tamaño: "3.4 MB",
|
||||
},
|
||||
];
|
||||
|
||||
export const REPOSITORIOS = [
|
||||
{
|
||||
id: "repo-1",
|
||||
nombre: "Materiales ISC 2024",
|
||||
descripcion: "Documentos de referencia para Ingeniería en Sistemas",
|
||||
cantidadArchivos: 45,
|
||||
},
|
||||
{
|
||||
id: "repo-2",
|
||||
nombre: "Lineamientos SEP",
|
||||
descripcion: "Documentos oficiales y normativas SEP actualizadas",
|
||||
cantidadArchivos: 12,
|
||||
},
|
||||
{
|
||||
id: "repo-3",
|
||||
nombre: "Bibliografía Digital",
|
||||
descripcion: "Recursos bibliográficos digitalizados",
|
||||
cantidadArchivos: 128,
|
||||
},
|
||||
{
|
||||
id: "repo-4",
|
||||
nombre: "Plantillas Institucionales",
|
||||
descripcion: "Formatos y plantillas oficiales ULSA",
|
||||
cantidadArchivos: 23,
|
||||
},
|
||||
];
|
||||
|
||||
export const PLANTILLAS_ANEXO_1 = [
|
||||
{
|
||||
id: "sep-2025",
|
||||
name: "Licenciatura RVOE SEP.docx",
|
||||
versions: ["v2025.2 (Vigente)", "v2025.1", "v2024.Final"],
|
||||
},
|
||||
{
|
||||
id: "interno-mix",
|
||||
name: "Estándar Institucional Mixto.docx",
|
||||
versions: ["v2.0", "v1.5", "v1.0-beta"],
|
||||
},
|
||||
{
|
||||
id: "conacyt",
|
||||
name: "Formato Posgrado CONAHCYT.docx",
|
||||
versions: ["v3.0 (2025)", "v2.8"],
|
||||
},
|
||||
];
|
||||
|
||||
export const PLANTILLAS_ANEXO_2 = [
|
||||
{
|
||||
id: "sep-2017-xlsx",
|
||||
name: "Licenciatura RVOE 2017.xlsx",
|
||||
versions: ["v2017.0", "v2018.1", "v2019.2", "v2020.Final"],
|
||||
},
|
||||
{
|
||||
id: "interno-mix-xlsx",
|
||||
name: "Estándar Institucional Mixto.xlsx",
|
||||
versions: ["v1.0", "v1.5"],
|
||||
},
|
||||
{
|
||||
id: "conacyt-xlsx",
|
||||
name: "Formato Posgrado CONAHCYT.xlsx",
|
||||
versions: ["v1.0", "v2.0"],
|
||||
},
|
||||
];
|
||||
@@ -2,19 +2,35 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { CARRERAS } from "../catalogs";
|
||||
|
||||
import type { NewPlanWizardState, PlanPreview } from "../types";
|
||||
import type { NewPlanWizardState, PlanPreview, TipoCiclo } from "../types";
|
||||
|
||||
export function useNuevoPlanWizard() {
|
||||
const [wizard, setWizard] = useState<NewPlanWizardState>({
|
||||
step: 1,
|
||||
modoCreacion: null,
|
||||
// datosBasicos: {
|
||||
// nombrePlan: "",
|
||||
// carreraId: "",
|
||||
// facultadId: "",
|
||||
// nivel: "",
|
||||
// tipoCiclo: "",
|
||||
// numCiclos: undefined,
|
||||
// plantillaPlanId: "",
|
||||
// plantillaPlanVersion: "",
|
||||
// plantillaMapaId: "",
|
||||
// plantillaMapaVersion: "",
|
||||
// },
|
||||
datosBasicos: {
|
||||
nombrePlan: "",
|
||||
carreraId: "",
|
||||
facultadId: "",
|
||||
nivel: "",
|
||||
nombrePlan: "Medicina",
|
||||
carreraId: "medico",
|
||||
facultadId: "med",
|
||||
nivel: "Licenciatura",
|
||||
tipoCiclo: "SEMESTRE",
|
||||
numCiclos: 8,
|
||||
plantillaPlanId: "sep-2025",
|
||||
plantillaPlanVersion: "v2025.2 (Vigente)",
|
||||
plantillaMapaId: "sep-2017-xlsx",
|
||||
plantillaMapaVersion: "v2017.0",
|
||||
},
|
||||
clonInterno: { planOrigenId: null },
|
||||
clonTradicional: {
|
||||
@@ -27,6 +43,8 @@ export function useNuevoPlanWizard() {
|
||||
poblacionObjetivo: "",
|
||||
notasAdicionales: "",
|
||||
archivosReferencia: [],
|
||||
repositoriosReferencia: [],
|
||||
archivosAdjuntos: [],
|
||||
},
|
||||
resumen: {},
|
||||
isLoading: false,
|
||||
@@ -46,12 +64,20 @@ export function useNuevoPlanWizard() {
|
||||
!!wizard.datosBasicos.carreraId &&
|
||||
!!wizard.datosBasicos.facultadId &&
|
||||
!!wizard.datosBasicos.nivel &&
|
||||
wizard.datosBasicos.numCiclos > 0;
|
||||
(wizard.datosBasicos.numCiclos !== undefined &&
|
||||
wizard.datosBasicos.numCiclos > 0) &&
|
||||
// Requerir ambas plantillas (plan y mapa) con versión
|
||||
!!wizard.datosBasicos.plantillaPlanId &&
|
||||
!!wizard.datosBasicos.plantillaPlanVersion &&
|
||||
!!wizard.datosBasicos.plantillaMapaId &&
|
||||
!!wizard.datosBasicos.plantillaMapaVersion;
|
||||
|
||||
const canContinueDesdeDetalles = (() => {
|
||||
if (wizard.modoCreacion === "MANUAL") return true;
|
||||
if (wizard.modoCreacion === "IA") {
|
||||
return !!wizard.iaConfig?.descripcionEnfoque;
|
||||
// Requerimos descripción del enfoque y notas adicionales
|
||||
return !!wizard.iaConfig?.descripcionEnfoque &&
|
||||
!!wizard.iaConfig?.notasAdicionales;
|
||||
}
|
||||
if (wizard.modoCreacion === "CLONADO") {
|
||||
if (wizard.subModoClonado === "INTERNO") {
|
||||
@@ -72,12 +98,24 @@ export function useNuevoPlanWizard() {
|
||||
const generarPreviewIA = async () => {
|
||||
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }));
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
// Ensure preview has the stricter types required by `PlanPreview`.
|
||||
let tipoCicloSafe: TipoCiclo;
|
||||
if (wizard.datosBasicos.tipoCiclo === "") {
|
||||
tipoCicloSafe = "SEMESTRE";
|
||||
} else {
|
||||
tipoCicloSafe = wizard.datosBasicos.tipoCiclo;
|
||||
}
|
||||
const numCiclosSafe: number =
|
||||
typeof wizard.datosBasicos.numCiclos === "number"
|
||||
? wizard.datosBasicos.numCiclos
|
||||
: 1;
|
||||
|
||||
const preview: PlanPreview = {
|
||||
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
|
||||
nivel: wizard.datosBasicos.nivel || "Licenciatura",
|
||||
tipoCiclo: wizard.datosBasicos.tipoCiclo,
|
||||
numCiclos: wizard.datosBasicos.numCiclos,
|
||||
numAsignaturasAprox: wizard.datosBasicos.numCiclos * 6,
|
||||
tipoCiclo: tipoCicloSafe,
|
||||
numCiclos: numCiclosSafe,
|
||||
numAsignaturasAprox: numCiclosSafe * 6,
|
||||
secciones: [
|
||||
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
|
||||
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
|
||||
@@ -20,20 +20,46 @@ export type NewPlanWizardState = {
|
||||
carreraId: string;
|
||||
facultadId: string;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
tipoCiclo: TipoCiclo | "";
|
||||
numCiclos: number | undefined;
|
||||
// Selección de plantillas (obligatorias)
|
||||
plantillaPlanId?: string;
|
||||
plantillaPlanVersion?: string;
|
||||
plantillaMapaId?: string;
|
||||
plantillaMapaVersion?: string;
|
||||
};
|
||||
clonInterno?: { planOrigenId: string | null };
|
||||
clonTradicional?: {
|
||||
archivoWordPlanId: string | null;
|
||||
archivoMapaExcelId: string | null;
|
||||
archivoAsignaturasExcelId: string | null;
|
||||
archivoWordPlanId:
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
}
|
||||
| null;
|
||||
archivoMapaExcelId: {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
} | null;
|
||||
archivoAsignaturasExcelId: {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
} | null;
|
||||
};
|
||||
iaConfig?: {
|
||||
descripcionEnfoque: string;
|
||||
poblacionObjetivo: string;
|
||||
notasAdicionales: string;
|
||||
archivosReferencia: Array<string>;
|
||||
repositoriosReferencia?: Array<string>;
|
||||
archivosAdjuntos?: Array<
|
||||
{ id: string; name: string; size: string; type: string }
|
||||
>;
|
||||
};
|
||||
resumen: { previewPlan?: PlanPreview };
|
||||
isLoading: boolean;
|
||||
@@ -2,10 +2,11 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
import * as TanStackQueryProvider from './integrations/tanstack-query/root-provider.tsx'
|
||||
import reportWebVitals from './reportWebVitals.ts'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
|
||||
|
||||
import './styles.css'
|
||||
|
||||
// Create a new router instance
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as PlanesPlanesListRouteRouteImport } from './routes/planes/PlanesListRoute'
|
||||
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
|
||||
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
|
||||
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
||||
@@ -44,6 +45,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesPlanesListRouteRoute = PlanesPlanesListRouteRouteImport.update({
|
||||
id: '/planes/PlanesListRoute',
|
||||
path: '/planes/PlanesListRoute',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
||||
id: '/demo/tanstack-query',
|
||||
path: '/demo/tanstack-query',
|
||||
@@ -142,6 +148,7 @@ export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
@@ -162,6 +169,7 @@ export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
@@ -182,6 +190,7 @@ export interface FileRoutesById {
|
||||
'/login': typeof LoginRoute
|
||||
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
|
||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
||||
@@ -205,6 +214,7 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/planes'
|
||||
| '/demo/tanstack-query'
|
||||
| '/planes/PlanesListRoute'
|
||||
| '/planes/$planId'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/nuevo'
|
||||
@@ -225,6 +235,7 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/planes'
|
||||
| '/demo/tanstack-query'
|
||||
| '/planes/PlanesListRoute'
|
||||
| '/planes/$planId'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
@@ -244,6 +255,7 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/planes/_lista'
|
||||
| '/demo/tanstack-query'
|
||||
| '/planes/PlanesListRoute'
|
||||
| '/planes/$planId/_detalle'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/_lista/nuevo'
|
||||
@@ -266,6 +278,7 @@ export interface RootRouteChildren {
|
||||
LoginRoute: typeof LoginRoute
|
||||
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
|
||||
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
||||
PlanesPlanesListRouteRoute: typeof PlanesPlanesListRouteRoute
|
||||
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||
}
|
||||
@@ -293,6 +306,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/planes/PlanesListRoute': {
|
||||
id: '/planes/PlanesListRoute'
|
||||
path: '/planes/PlanesListRoute'
|
||||
fullPath: '/planes/PlanesListRoute'
|
||||
preLoaderRoute: typeof PlanesPlanesListRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/tanstack-query': {
|
||||
id: '/demo/tanstack-query'
|
||||
path: '/demo/tanstack-query'
|
||||
@@ -486,6 +506,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
LoginRoute: LoginRoute,
|
||||
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
|
||||
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
||||
PlanesPlanesListRouteRoute: PlanesPlanesListRouteRoute,
|
||||
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
|
||||
PlanesPlanIdAsignaturasRouteRoute:
|
||||
PlanesPlanIdAsignaturasRouteRouteWithChildren,
|
||||
|
||||
@@ -18,6 +18,12 @@ export const Route = createFileRoute('/planes/$planId/_detalle/datos')({
|
||||
})
|
||||
|
||||
function DatosGeneralesPage() {
|
||||
const {data, isFetching} = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f');
|
||||
if(!isFetching && !data) {
|
||||
return <div>No se encontró el plan de estudios.</div>
|
||||
}
|
||||
console.log(data);
|
||||
|
||||
// 1. Definimos los DATOS iniciales (Lo que antes venía por props)
|
||||
const [campos, setCampos] = useState<DatosGeneralesField[]>([
|
||||
{ id: '1', label: 'Objetivo General', value: 'Formar profesionales...', requerido: true, tipo: 'texto' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { NuevaAsignaturaModalContainer } from '@/features/asignaturas/new/NuevaAsignaturaModalContainer'
|
||||
import { NuevaAsignaturaModalContainer } from '@/features/asignaturas/nueva/NuevaAsignaturaModalContainer'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/planes/$planId/asignaturas/_lista/nueva',
|
||||
|
||||
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,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import NuevoPlanModalContainer from '@/features/planes/new/NuevoPlanModalContainer'
|
||||
import NuevoPlanModalContainer from '@/features/planes/nuevo/NuevoPlanModalContainer'
|
||||
|
||||
export const Route = createFileRoute('/planes/_lista/nuevo')({
|
||||
component: NuevoPlanModalContainer,
|
||||
|
||||
@@ -183,10 +183,19 @@ function RouteComponent() {
|
||||
|
||||
// Filtrado de planes
|
||||
const filteredPlans = useMemo(() => {
|
||||
const term = search.trim().toLowerCase()
|
||||
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD') // Descompone "á" en "a" + "´"
|
||||
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
||||
.toLowerCase() // Convierte a minúsculas
|
||||
}
|
||||
// Limpiamos el término de búsqueda una sola vez antes de filtrar
|
||||
const term = cleanText(search.trim())
|
||||
return planes.filter((p) => {
|
||||
const matchName = term
|
||||
? p.nombrePrograma.toLowerCase().includes(term)
|
||||
? // Limpiamos también el nombre del programa antes de comparar
|
||||
cleanText(p.nombrePrograma).includes(term)
|
||||
: true
|
||||
const matchFac =
|
||||
facultadSel === 'todas' ? true : p.facultadId === facultadSel
|
||||
|
||||
BIN
src/types/supabase.ts
Normal file
BIN
src/types/supabase.ts
Normal file
Binary file not shown.
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