From ed318fa67b262fb63867d6afd68817180ffdc012 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Mon, 23 Mar 2026 13:11:24 -0600 Subject: [PATCH] =?UTF-8?q?Funcional=20pero=20falta=20arreglar=20dise?= =?UTF-8?q?=C3=B1o=20y=20responsividad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 4 +- package.json | 2 +- .../wizard/PasoBasicosClonadoInterno.tsx | 93 +++++ .../PasoBasicosForm/PasoBasicosForm.tsx | 14 + .../wizard/PasoFuenteClonadoInterno.tsx | 360 ++++++++++++++++++ .../asignaturas/wizard/WizardControls.tsx | 80 ++++ .../pagination/pagination-03.tsx | 128 +++++++ src/components/ui/pagination.tsx | 127 ++++++ .../nueva/NuevaAsignaturaModalContainer.tsx | 49 ++- src/features/asignaturas/nueva/types.ts | 8 +- 10 files changed, 850 insertions(+), 15 deletions(-) create mode 100644 src/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx create mode 100644 src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx create mode 100644 src/components/shadcn-studio/pagination/pagination-03.tsx create mode 100644 src/components/ui/pagination.tsx diff --git a/bun.lock b/bun.lock index 4452943..33d5fdd 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", - "tailwind-merge": "^3.4.0", + "tailwind-merge": "^3.5.0", "tailwindcss": "^4.0.6", "tw-animate-css": "^1.3.6", "use-debounce": "^10.1.0", @@ -1327,7 +1327,7 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], diff --git a/package.json b/package.json index 526ff2a..79ce162 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", - "tailwind-merge": "^3.4.0", + "tailwind-merge": "^3.5.0", "tailwindcss": "^4.0.6", "tw-animate-css": "^1.3.6", "use-debounce": "^10.1.0", diff --git a/src/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx b/src/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx new file mode 100644 index 0000000..f2aa925 --- /dev/null +++ b/src/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx @@ -0,0 +1,93 @@ +import * as Icons from 'lucide-react' +import { useEffect, useRef } from 'react' + +import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' + +import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { useSubject } from '@/data' + +export function PasoBasicosClonadoInterno({ + wizard, + onChange, +}: { + wizard: NewSubjectWizardState + onChange: React.Dispatch> +}) { + const sourceId = wizard.clonInterno?.asignaturaOrigenId ?? null + const { data: source, isLoading, isError } = useSubject(sourceId) + + const lastAppliedRef = useRef(null) + + useEffect(() => { + if (!source) return + if (lastAppliedRef.current === source.id) return + + lastAppliedRef.current = source.id + + onChange((w) => ({ + ...w, + datosBasicos: { + ...w.datosBasicos, + nombre: source.nombre, + codigo: source.codigo ?? '', + tipo: (source.tipo as any) ?? null, + creditos: source.creditos, + horasAcademicas: (source as any).horas_academicas ?? null, + horasIndependientes: (source as any).horas_independientes ?? null, + estructuraId: (source.estructura_id ?? + w.datosBasicos.estructuraId) as any, + }, + })) + }, [onChange, source]) + + if (!sourceId) { + return ( + + + Datos básicos + + + Selecciona una asignatura fuente para continuar. + + + ) + } + + if (isLoading) { + return ( + + + Datos básicos + + + Cargando información de la asignatura fuente… + + + ) + } + + if (isError || !source) { + return ( + + + + + No se pudo cargar la fuente + + + + Intenta seleccionar otra asignatura. + + + ) + } + + return ( + + ) +} diff --git a/src/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm.tsx b/src/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm.tsx index cba5dfd..1af9315 100644 --- a/src/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm.tsx +++ b/src/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm.tsx @@ -1,3 +1,4 @@ +import * as Icons from 'lucide-react' import { useEffect, useState } from 'react' import PasoSugerenciasForm from './PasoSugerenciasForm' @@ -21,9 +22,11 @@ import { cn } from '@/lib/utils' export function PasoBasicosForm({ wizard, onChange, + estructuraFuenteId, }: { wizard: NewSubjectWizardState onChange: React.Dispatch> + estructuraFuenteId?: string | null }) { const { data: estructuras } = useSubjectEstructuras() @@ -258,6 +261,17 @@ export function PasoBasicosForm({ )} + {estructuraFuenteId && + wizard.datosBasicos.estructuraId && + wizard.datosBasicos.estructuraId !== estructuraFuenteId ? ( +
+ + + Es posible que se pierdan datos generales al seleccionar otra + estructura. + +
+ ) : null}

Define los campos requeridos (ej. Objetivos, Temario, Evaluación).

diff --git a/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx b/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx new file mode 100644 index 0000000..7178f34 --- /dev/null +++ b/src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx @@ -0,0 +1,360 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import * as Icons from 'lucide-react' +import { useEffect, useMemo } from 'react' +import { useDebounce } from 'use-debounce' + +import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' + +import Pagination03 from '@/components/shadcn-studio/pagination/pagination-03' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { supabaseBrowser, useCatalogosPlanes, usePlanes } from '@/data' +import { cn } from '@/lib/utils' + +type SourceSubjectRow = { + id: string + nombre: string + codigo: string | null + creditos: number + tipo: any + plan_estudio_id: string + estructura_id: string | null +} + +const ALL = '__all__' + +const normalizeLikeTerm = (term: string) => + term.trim().replace(/[(),]/g, ' ').replace(/\s+/g, ' ') + +export function PasoFuenteClonadoInterno({ + wizard, + onChange, +}: { + wizard: NewSubjectWizardState + onChange: React.Dispatch> +}) { + const pageSize = 20 + + const facultadId = wizard.clonInterno?.facultadId ?? null + const carreraId = wizard.clonInterno?.carreraId ?? null + const planOrigenId = wizard.clonInterno?.planOrigenId ?? null + const search = wizard.clonInterno?.search ?? '' + const page = Math.max(1, wizard.clonInterno?.page ?? 1) + + const [debouncedSearch] = useDebounce(search, 350) + + const { data: catalogos } = useCatalogosPlanes() + + const carrerasOptions = useMemo(() => { + const raw = catalogos?.carreras ?? [] + return facultadId ? raw.filter((c) => c.facultad_id === facultadId) : raw + }, [catalogos?.carreras, facultadId]) + + const planesQuery = usePlanes({ + search: '', + facultadId: facultadId ?? 'todas', + carreraId: carreraId ?? 'todas', + estadoId: 'todos', + limit: 500, + offset: 0, + } as any) + + const needPlansForFilter = Boolean((facultadId || carreraId) && !planOrigenId) + const plansForFilter = planesQuery.data?.data ?? [] + + const { data: subjectsPaged, isLoading: subjectsLoading } = useQuery({ + queryKey: [ + 'asignaturas', + 'clone-source', + { + facultadId, + carreraId, + planOrigenId, + search: debouncedSearch, + page, + pageSize, + planIdsKey: needPlansForFilter + ? plansForFilter.map((p) => p.id).join(',') + : null, + }, + ], + enabled: !needPlansForFilter || !planesQuery.isLoading, + placeholderData: keepPreviousData, + queryFn: async () => { + const supabase = supabaseBrowser() + + const from = (page - 1) * pageSize + const to = from + pageSize - 1 + + let q = supabase + .from('asignaturas') + .select( + 'id,nombre,codigo,creditos,tipo,plan_estudio_id,estructura_id', + { + count: 'exact', + }, + ) + .order('nombre', { ascending: true }) + + if (planOrigenId) { + q = q.eq('plan_estudio_id', planOrigenId) + } else if (needPlansForFilter) { + const planIds = plansForFilter.map((p) => p.id) + if (!planIds.length) { + return { data: [] as Array, count: 0 } + } + q = q.in('plan_estudio_id', planIds) + } + + const term = normalizeLikeTerm(debouncedSearch) + if (term) { + // PostgREST OR syntax + q = q.or(`nombre.ilike.%${term}%,codigo.ilike.%${term}%`) + } + + q = q.range(from, to) + + const { data, error, count } = await q + if (error) throw new Error(error.message) + + return { + data: data as unknown as Array, + count: count ?? 0, + } + }, + }) + + const subjects = subjectsPaged?.data ?? [] + const total = subjectsPaged?.count ?? 0 + const pageCount = Math.max(1, Math.ceil(total / pageSize)) + + useEffect(() => { + // clamp page if results shrink + if (page > pageCount) { + onChange((w) => ({ + ...w, + clonInterno: { ...(w.clonInterno ?? {}), page: pageCount }, + })) + } + }, [onChange, page, pageCount]) + + const patchClonInterno = ( + patch: Partial>, + ) => + onChange((w) => ({ + ...w, + clonInterno: { ...(w.clonInterno ?? {}), ...patch }, + })) + + const hasAnyFilter = Boolean( + facultadId || carreraId || planOrigenId || search.trim().length, + ) + + const clearDisabled = !hasAnyFilter + + const selectedId = wizard.clonInterno?.asignaturaOrigenId ?? null + + const resetSelection = () => patchClonInterno({ asignaturaOrigenId: null }) + + return ( +
+ + + Fuente + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + + patchClonInterno({ search: e.target.value, page: 1 }) + } + /> +
+
+ +
+
+
+
+ +
+
+ Selecciona una asignatura fuente (solo una). +
+ +
+ {subjectsLoading ? ( +
+ Cargando asignaturas… +
+ ) : subjects.length === 0 ? ( +
+ No hay asignaturas con esos filtros. +
+ ) : ( + subjects.map((m) => { + const active = String(selectedId) === String(m.id) + return ( + + ) + }) + )} +
+ + {pageCount > 1 ? ( + patchClonInterno({ page: nextPage })} + /> + ) : null} +
+
+ ) +} diff --git a/src/components/asignaturas/wizard/WizardControls.tsx b/src/components/asignaturas/wizard/WizardControls.tsx index c9a5c16..2cc56c5 100644 --- a/src/components/asignaturas/wizard/WizardControls.tsx +++ b/src/components/asignaturas/wizard/WizardControls.tsx @@ -15,6 +15,7 @@ import { qk, useCreateSubjectManual, subjects_get_maybe, + subjects_get, } from '@/data' export function WizardControls({ @@ -201,6 +202,85 @@ export function WizardControls({ let startedWaiting = false try { + if (wizard.tipoOrigen === 'CLONADO_INTERNO') { + if (!wizard.plan_estudio_id) { + throw new Error('Plan de estudio inválido.') + } + + const asignaturaOrigenId = wizard.clonInterno?.asignaturaOrigenId + if (!asignaturaOrigenId) { + throw new Error('Selecciona una asignatura fuente.') + } + + if (!wizard.datosBasicos.estructuraId) { + throw new Error('Estructura inválida.') + } + if (!wizard.datosBasicos.nombre.trim()) { + throw new Error('Nombre inválido.') + } + if (wizard.datosBasicos.tipo == null) { + throw new Error('Tipo inválido.') + } + if (wizard.datosBasicos.creditos == null) { + throw new Error('Créditos inválidos.') + } + + const fuente = await subjects_get(asignaturaOrigenId as any) + + const supabase = supabaseBrowser() + + const codigo = (wizard.datosBasicos.codigo ?? '').trim() + + const payload: TablesInsert<'asignaturas'> = { + plan_estudio_id: wizard.plan_estudio_id, + estructura_id: wizard.datosBasicos.estructuraId, + codigo: codigo ? codigo : null, + nombre: wizard.datosBasicos.nombre, + tipo: wizard.datosBasicos.tipo, + creditos: wizard.datosBasicos.creditos, + datos: (fuente as any).datos, + contenido_tematico: (fuente as any).contenido_tematico, + criterios_de_evaluacion: (fuente as any).criterios_de_evaluacion, + tipo_origen: 'CLONADO_INTERNO', + meta_origen: { + ...(fuente as any).meta_origen, + asignatura_origen_id: fuente.id, + plan_origen_id: (fuente as any).plan_estudio_id, + }, + horas_academicas: + wizard.datosBasicos.horasAcademicas ?? + (fuente as any).horas_academicas ?? + null, + horas_independientes: + wizard.datosBasicos.horasIndependientes ?? + (fuente as any).horas_independientes ?? + null, + } + + const { data: inserted, error: insertError } = await supabase + .from('asignaturas') + .insert(payload) + .select('id,plan_estudio_id') + .single() + + if (insertError) throw new Error(insertError.message) + + qc.invalidateQueries({ + queryKey: qk.planAsignaturas(wizard.plan_estudio_id), + }) + qc.invalidateQueries({ + queryKey: qk.planHistorial(wizard.plan_estudio_id), + }) + + navigate({ + to: `/planes/${inserted.plan_estudio_id}/asignaturas/${inserted.id}`, + state: { showConfetti: true }, + resetScroll: false, + }) + + return + } + if (wizard.tipoOrigen === 'IA_SIMPLE') { if (!wizard.plan_estudio_id) { throw new Error('Plan de estudio inválido.') diff --git a/src/components/shadcn-studio/pagination/pagination-03.tsx b/src/components/shadcn-studio/pagination/pagination-03.tsx new file mode 100644 index 0000000..9bc9756 --- /dev/null +++ b/src/components/shadcn-studio/pagination/pagination-03.tsx @@ -0,0 +1,128 @@ +import { buttonVariants } from '@/components/ui/button' +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination' +import { cn } from '@/lib/utils' + +export type Pagination03Props = { + page: number + pageCount: number + onPageChange: (page: number) => void + className?: string +} + +const toInt = (n: unknown, fallback: number) => { + const x = typeof n === 'number' ? n : Number(n) + return Number.isFinite(x) ? Math.floor(x) : fallback +} + +function getPageItems(page: number, pageCount: number): Array { + if (pageCount <= 7) { + return Array.from({ length: pageCount }, (_, i) => i + 1) + } + + const items: Array = [] + const safePage = Math.min(Math.max(page, 1), pageCount) + + items.push(1) + + const start = Math.max(2, safePage - 1) + const end = Math.min(pageCount - 1, safePage + 1) + + if (start > 2) items.push('...') + for (let p = start; p <= end; p++) items.push(p) + if (end < pageCount - 1) items.push('...') + + items.push(pageCount) + return items +} + +export function Pagination03({ + page, + pageCount, + onPageChange, + className, +}: Pagination03Props) { + const safePageCount = Math.max(1, toInt(pageCount, 1)) + const safePage = Math.min(Math.max(toInt(page, 1), 1), safePageCount) + + const items = getPageItems(safePage, safePageCount) + const canPrev = safePage > 1 + const canNext = safePage < safePageCount + + const go = (p: number) => { + const next = Math.min(Math.max(p, 1), safePageCount) + if (next === safePage) return + onPageChange(next) + } + + return ( + + + + { + e.preventDefault() + if (!canPrev) return + go(safePage - 1) + }} + /> + + + {items.map((it, idx) => + it === '...' ? ( + + + + ) : ( + + { + e.preventDefault() + go(it) + }} + className={ + it === safePage + ? cn( + buttonVariants({ + variant: 'default', + size: 'icon', + }), + 'hover:text-primary-foreground! dark:bg-primary dark:text-primary-foreground dark:hover:text-primary-foreground dark:hover:bg-primary/90 shadow-none! dark:border-transparent', + ) + : undefined + } + > + {it} + + + ), + )} + + + { + e.preventDefault() + if (!canNext) return + go(safePage + 1) + }} + /> + + + + ) +} + +export default Pagination03 diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..1dcfb0c --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { buttonVariants, type Button } from "@/components/ui/button" + +function Pagination({ className, ...props }: React.ComponentProps<"nav">) { + return ( +