From 88c6dc6b4d6a7d8d68b7c1d2e762d001d46851d9 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Thu, 12 Mar 2026 13:47:56 -0600 Subject: [PATCH 1/2] wip --- src/components/ui/radio-group.tsx | 43 +++++++ .../nueva/NuevaBibliografiaModalContainer.tsx | 112 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/components/ui/radio-group.tsx diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..20416f0 --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { CircleIcon } from "lucide-react" +import { RadioGroup as RadioGroupPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx b/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx index 7ce8ef8..432ed1a 100644 --- a/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx +++ b/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx @@ -38,6 +38,7 @@ import { import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectContent, @@ -45,6 +46,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' import { Textarea } from '@/components/ui/textarea' import { Tooltip, @@ -57,6 +59,115 @@ import { buscar_bibliografia } from '@/data' import { useCreateBibliografia } from '@/data/hooks/useSubjects' import { cn } from '@/lib/utils' +export function BookSelectionAccordion() { + // Estado inicial indefinido para que nada esté seleccionado por defecto + const [selectedBook, setSelectedBook] = useState( + undefined, + ) + + return ( + <> + {/* Un solo RadioGroup controla ambos lados */} + + {/* --- LADO IZQUIERDO: Sugerencia Online --- */} +
+

+ Sugerencia Original (Open Library) +

+ +
+ + +
+
+ + {/* Separador vertical para escritorio, horizontal en móviles */} + + + + {/* --- LADO DERECHO: Alternativas de Biblioteca --- */} +
+

+ Disponibles en Biblioteca +

+ +
+ {/* Opcion 1: Coincidencia exacta */} +
+ + +
+ + {/* Opcion 2: Edición anterior */} +
+ + +
+
+
+
+ + ) +} + type MetodoBibliografia = 'MANUAL' | 'EN_LINEA' | null export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago' @@ -1056,6 +1167,7 @@ function MetodoStep({ + ) } From 3acea813b60531343346c76d5d1c85f7adb24991 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Thu, 12 Mar 2026 16:17:58 -0600 Subject: [PATCH 2/2] =?UTF-8?q?close=20#169:=20Se=20actualiz=C3=B3=20el=20?= =?UTF-8?q?modal=20de=20nueva=20bibliograf=C3=ADa=20y=20se=20a=C3=B1adi?= =?UTF-8?q?=C3=B3=20el=20paso=20"Biblioteca"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se unificó el stepper en cinco pasos y se configuró para omitir el paso "Biblioteca" cuando el método sea MANUAL. - Se añadió el paso "Biblioteca" con un accordion múltiple para comparar cada sugerencia con alternativas de la biblioteca; se eliminaron los estados de "Buscando" y su badge. - Se incorporaron tres conjuntos hardcodeados de coincidencias (0, 2 y 5) que se asignan al azar si la sugerencia no trae datos de biblioteca; si no hay coincidencias la sugerencia se marca automáticamente como mantenida. - Se implementó BookSelectionAccordion para elegir conservar la sugerencia o sustituirla por una coincidencia; se preservó el estilo visual de las opciones. - Se añadieron validaciones y comportamientos de navegación: bloqueo de avance si quedan accordions por revisar, apertura y scroll al primer accordion sin resolver, y salto del paso "Biblioteca" en modo MANUAL. --- .../wizard/WizardResponsiveHeader.tsx | 21 +- .../nueva/NuevaBibliografiaModalContainer.tsx | 694 +++++++++++++++--- 2 files changed, 610 insertions(+), 105 deletions(-) diff --git a/src/components/wizard/WizardResponsiveHeader.tsx b/src/components/wizard/WizardResponsiveHeader.tsx index 39f8427..50583d5 100644 --- a/src/components/wizard/WizardResponsiveHeader.tsx +++ b/src/components/wizard/WizardResponsiveHeader.tsx @@ -5,16 +5,24 @@ export function WizardResponsiveHeader({ wizard, methods, titleOverrides, + hiddenStepIds, }: { wizard: any methods: any titleOverrides?: Record + hiddenStepIds?: Array }) { - const idx = wizard.utils.getIndex(methods.current.id) - const totalSteps = wizard.steps.length - const currentIndex = idx + 1 - const hasNextStep = idx < totalSteps - 1 - const nextStep = wizard.steps[currentIndex] + const hidden = new Set(hiddenStepIds ?? []) + const visibleSteps = (wizard.steps as Array).filter( + (s) => s && !hidden.has(s.id), + ) + + const idx = visibleSteps.findIndex((s) => s.id === methods.current.id) + const safeIdx = idx >= 0 ? idx : 0 + const totalSteps = visibleSteps.length + const currentIndex = Math.min(safeIdx + 1, totalSteps) + const hasNextStep = safeIdx < totalSteps - 1 + const nextStep = visibleSteps[safeIdx + 1] const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title @@ -45,10 +53,11 @@ export function WizardResponsiveHeader({
- {wizard.steps.map((step: any) => ( + {visibleSteps.map((step: any, visibleIdx: number) => ( diff --git a/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx b/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx index 432ed1a..36f9b8e 100644 --- a/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx +++ b/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx @@ -26,6 +26,12 @@ import type { import type { TablesInsert } from '@/types/supabase' import { defineStepper } from '@/components/stepper' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -59,43 +65,157 @@ import { buscar_bibliografia } from '@/data' import { useCreateBibliografia } from '@/data/hooks/useSubjects' import { cn } from '@/lib/utils' -export function BookSelectionAccordion() { +type BibliotecaOption = { + id: string + title: string + authors: Array + publisher?: string + year?: number + isbn?: string + shelf?: string + badgeText?: string +} + +type BibliotecaOptionTemplate = Omit + +// Hardcodeado: 3 conjuntos de coincidencias (0, 2 y 5). +const BIBLIOTECA_MATCH_SETS: Array> = [ + [], + [ + { + title: 'Coincidencia en biblioteca (Ejemplar 1)', + authors: ['Autor A', 'Autor B'], + publisher: 'Editorial X', + year: 2020, + isbn: '9780000000001', + shelf: 'QA76.9 .A1 2020', + badgeText: 'Coincidencia ISBN', + }, + { + title: 'Coincidencia en biblioteca (Ejemplar 2)', + authors: ['Autor C'], + publisher: 'Editorial Y', + year: 2016, + shelf: 'QA76.9 .A2 2016', + }, + ], + [ + { + title: 'Coincidencia en biblioteca (Ejemplar 1)', + authors: ['Autor A', 'Autor B'], + publisher: 'Editorial X', + year: 2020, + isbn: '9780000000001', + shelf: 'QA76.9 .A1 2020', + badgeText: 'Coincidencia ISBN', + }, + { + title: 'Coincidencia en biblioteca (Ejemplar 2)', + authors: ['Autor C'], + publisher: 'Editorial Y', + year: 2016, + shelf: 'QA76.9 .A2 2016', + }, + { + title: 'Coincidencia en biblioteca (Ejemplar 3)', + authors: ['Autor D', 'Autor E'], + publisher: 'Editorial Z', + year: 2014, + shelf: 'QA76.9 .A3 2014', + }, + { + title: 'Coincidencia en biblioteca (Ejemplar 4)', + authors: ['Autor F'], + publisher: 'Editorial W', + year: 2011, + shelf: 'QA76.9 .A4 2011', + }, + { + title: 'Coincidencia en biblioteca (Ejemplar 5)', + authors: ['Autor G'], + publisher: 'Editorial V', + year: 2009, + shelf: 'QA76.9 .A5 2009', + }, + ], +] + +export function BookSelectionAccordion({ + onlineSourceLabel, + online, + options, + value, + onValueChange, +}: { + onlineSourceLabel: string + online: { + id: string + title: string + authorsLine: string + year?: number + isbn?: string + } + options: Array + value: string | undefined + onValueChange: (value: string) => void +}) { // Estado inicial indefinido para que nada esté seleccionado por defecto - const [selectedBook, setSelectedBook] = useState( - undefined, - ) + const [selectedBook, setSelectedBook] = useState(value) + + useEffect(() => { + setSelectedBook(value) + }, [value]) + + const onlineValue = `online:${online.id}` + + const optionBaseClass = + 'relative flex items-start space-x-3 rounded-lg border p-4 transition-colors' + + const optionClass = (isSelected: boolean) => + cn( + optionBaseClass, + isSelected + ? 'border-primary bg-primary/5' + : 'hover:border-primary/30 hover:bg-accent/50', + ) return ( <> {/* Un solo RadioGroup controla ambos lados */} { + setSelectedBook(v) + onValueChange(v) + }} className="flex flex-col gap-6 md:flex-row" > {/* --- LADO IZQUIERDO: Sugerencia Online --- */}

- Sugerencia Original (Open Library) + Sugerencia Original ({onlineSourceLabel})

-
- +
+
@@ -110,57 +230,49 @@ export function BookSelectionAccordion() { Disponibles en Biblioteca -
- {/* Opcion 1: Coincidencia exacta */} -
- - -
- - {/* Opcion 2: Edición anterior */} -
- - -
+
+ {options.length === 0 ? ( +
+ No se encontraron alternativas. +
+ ) : ( + options.map((opt) => { + const optValue = `biblio:${opt.id}` + const authorsLine = opt.authors.join('; ') + const isSelected = selectedBook === optValue + return ( +
+ + +
+ ) + }) + )}
@@ -267,6 +379,10 @@ type WizardState = { selected: boolean endpoint: EndpointResult['endpoint'] item: GoogleBooksVolume | OpenLibraryDoc + biblioteca?: { + options?: Array + choiceId?: string + } }> isLoading: boolean errorMessage: string | null @@ -303,10 +419,96 @@ const Wizard = defineStepper( title: 'Datos básicos', description: 'Seleccionar o capturar', }, + { + id: 'biblioteca', + title: 'Biblioteca', + description: 'Comparar con alternativas de la biblioteca', + }, { id: 'paso3', title: 'Detalles', description: 'Formato y citas' }, { id: 'resumen', title: 'Resumen', description: 'Confirmar' }, ) +type BibliotecaStepHandle = { + validateBeforeNext: () => boolean +} + +function bibliotecaOptionToRef(opt: BibliotecaOption): BibliografiaRef { + return { + id: opt.id, + raw: undefined, + title: opt.title, + subtitle: undefined, + authors: opt.authors, + publisher: opt.publisher, + year: opt.year, + isbn: opt.isbn, + tipo: 'BASICA', + } +} + +function getOnlineSuggestionTitle(s: IASugerencia): string { + if (s.endpoint === 'google') { + const info = (s.item as GoogleBooksVolume).volumeInfo ?? {} + return (info.title ?? '').trim() || 'Sin título' + } + + const doc = s.item as OpenLibraryDoc + return ( + (typeof doc['title'] === 'string' ? doc['title'] : '').trim() || + 'Sin título' + ) +} + +function getOnlineSuggestionAuthors(s: IASugerencia): Array { + if (s.endpoint === 'google') { + const info = (s.item as GoogleBooksVolume).volumeInfo ?? {} + return Array.isArray(info.authors) ? info.authors : [] + } + + const doc = s.item as OpenLibraryDoc + return Array.isArray(doc['author_name']) + ? (doc['author_name'] as Array).filter( + (a): a is string => typeof a === 'string', + ) + : [] +} + +function getOnlineSuggestionIsbn(s: IASugerencia): string | undefined { + if (s.endpoint === 'google') { + const info = (s.item as GoogleBooksVolume).volumeInfo + const isbn = info?.industryIdentifiers?.find( + (x) => x.identifier, + )?.identifier + return typeof isbn === 'string' && isbn.trim() ? isbn.trim() : undefined + } + + const doc = s.item as OpenLibraryDoc + const isbn = Array.isArray(doc['isbn']) + ? (doc['isbn'] as Array).find( + (x): x is string => typeof x === 'string', + ) + : undefined + return typeof isbn === 'string' && isbn.trim() ? isbn.trim() : undefined +} + +function getOnlineSuggestionYear(s: IASugerencia): number | undefined { + return s.endpoint === 'google' + ? tryParseYear((s.item as GoogleBooksVolume).volumeInfo?.publishedDate) + : tryParseYearFromOpenLibrary(s.item as OpenLibraryDoc) +} + +function iaSugerenciaToChosenRef(s: IASugerencia): BibliografiaRef { + const choiceId = s.biblioteca?.choiceId + const options = s.biblioteca?.options + + if (choiceId && choiceId !== 'online' && Array.isArray(options)) { + const chosen = options.find((o) => o.id === choiceId) + if (chosen) return bibliotecaOptionToRef(chosen) + } + + return endpointResultToRef(iaSugerenciaToEndpointResult(s)) +} + function parsearAutor(nombreCompleto: string): CSLAuthor { if (nombreCompleto.includes(',')) { return { @@ -557,6 +759,7 @@ export function NuevaBibliografiaModalContainer({ const createBibliografia = useCreateBibliografia() const formatoStepRef = useRef(null) + const bibliotecaStepRef = useRef(null) const [wizard, setWizard] = useState({ metodo: null, @@ -594,9 +797,9 @@ export function NuevaBibliografiaModalContainer({ const styleCacheRef = useRef(new Map()) const localeCacheRef = useRef(new Map()) - const titleOverrides = + const titleOverrides: Record = wizard.metodo === 'EN_LINEA' - ? { paso2: 'Sugerencias', paso3: 'Estructura' } + ? { paso2: 'Sugerencias', biblioteca: 'Biblioteca', paso3: 'Estructura' } : { paso2: 'Datos básicos', paso3: 'Detalles' } const handleClose = () => { @@ -610,7 +813,7 @@ export function NuevaBibliografiaModalContainer({ wizard.metodo === 'EN_LINEA' ? wizard.ia.sugerencias .filter((s) => s.selected) - .map((s) => endpointResultToRef(iaSugerenciaToEndpointResult(s))) + .map((s) => iaSugerenciaToChosenRef(s)) : wizard.manual.refs // Mantener `wizard.refs` como snapshot para pasos 3/4. @@ -900,14 +1103,17 @@ export function NuevaBibliografiaModalContainer({ } } + const WizardDef = Wizard as any + return ( - - {({ methods }) => { - const idx = Wizard.utils.getIndex(methods.current.id) - const isLast = idx >= Wizard.steps.length - 1 + {({ methods }: any) => { + const idx = WizardDef.utils.getIndex(methods.current.id) + const isLast = idx >= WizardDef.steps.length - 1 + const currentId = methods.current.id as string return ( } footerSlot={ - +
)}
-
+ } >
@@ -974,8 +1266,8 @@ export function NuevaBibliografiaModalContainer({ ) : null} - {idx === 0 && ( - + {currentId === 'metodo' && ( + @@ -987,11 +1279,11 @@ export function NuevaBibliografiaModalContainer({ })) } /> - + )} - {idx === 1 && ( - + {currentId === 'paso2' && ( + {wizard.metodo === 'EN_LINEA' ? ( )} - + )} - {idx === 2 && ( - + {currentId === 'biblioteca' && wizard.metodo === 'EN_LINEA' && ( + + s.selected, + )} + onPatchSugerencia={(id, patch) => + setWizard((w) => ({ + ...w, + ia: { + ...w.ia, + sugerencias: w.ia.sugerencias.map((s) => + s.id === id ? { ...s, ...patch } : s, + ), + }, + })) + } + /> + + )} + + {currentId === 'paso3' && ( + - + )} - {idx === 3 && ( - + {currentId === 'resumen' && ( + - + )}
) }} -
+ ) } @@ -1167,7 +1481,6 @@ function MetodoStep({ -
) } @@ -1460,6 +1773,189 @@ function SugerenciasStep({ ) } +type BibliotecaStepProps = { + sugerencias: Array + onPatchSugerencia: (id: string, patch: Partial) => void +} + +const BibliotecaStep = forwardRef( + function BibliotecaStep({ sugerencias, onPatchSugerencia }, ref) { + const [openIds, setOpenIds] = useState>([]) + const anchorRefs = useRef>({}) + const initializedRef = useRef(new Set()) + + const scrollToAccordion = (id: string) => { + const el = anchorRefs.current[id] + if (!el) return + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + + useEffect(() => { + for (const s of sugerencias) { + const b = s.biblioteca + const hasOptions = Array.isArray(b?.options) + if (hasOptions) continue + if (initializedRef.current.has(s.id)) continue + + initializedRef.current.add(s.id) + + const setIdx = Math.floor(Math.random() * 3) + const templates = BIBLIOTECA_MATCH_SETS[setIdx] ?? [] + const options: Array = templates.map((t, i) => ({ + id: `biblio:${s.id}:${i + 1}`, + ...t, + })) + + onPatchSugerencia(s.id, { + biblioteca: { + options, + choiceId: options.length === 0 ? 'online' : undefined, + }, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sugerencias]) + + const validateBeforeNext = () => { + const unresolved = sugerencias.find((s) => { + const b = s.biblioteca + if (!b || !Array.isArray(b.options)) return true + if (b.options.length === 0) return false + return !b.choiceId + }) + + if (!unresolved) return true + + setOpenIds((prev) => + prev.includes(unresolved.id) ? prev : [...prev, unresolved.id], + ) + requestAnimationFrame(() => scrollToAccordion(unresolved.id)) + return false + } + + useImperativeHandle(ref, () => ({ validateBeforeNext })) + + return ( +
+ + + Comparar con alternativas de la biblioteca + + Conserva la sugerencia original o sustitúyela por una + coincidencia. + + + + + + {sugerencias.map((s) => { + const title = getOnlineSuggestionTitle(s) + const authors = getOnlineSuggestionAuthors(s) + const authorsLine = authors.join('; ') || '—' + const year = getOnlineSuggestionYear(s) + const isbn = getOnlineSuggestionIsbn(s) + const sourceLabel = + s.endpoint === 'google' ? 'Google Books' : 'Open Library' + + const b = s.biblioteca + const options = b?.options ?? [] + + const badgeState: 'por_revisar' | 'sustituido' | 'mantenido' = + !b || !Array.isArray(b.options) + ? 'por_revisar' + : options.length === 0 + ? 'mantenido' + : !b.choiceId + ? 'por_revisar' + : b.choiceId === 'online' + ? 'mantenido' + : 'sustituido' + + const badge = + badgeState === 'por_revisar' ? ( + + Por revisar + + ) : badgeState === 'sustituido' ? ( + + Sustituido + + ) : ( + + Mantenido + + ) + + const radioValue = + b?.choiceId === 'online' || (options.length === 0 && !b?.choiceId) + ? `online:${s.id}` + : typeof b?.choiceId === 'string' + ? `biblio:${b.choiceId}` + : undefined + + return ( + +
{ + anchorRefs.current[s.id] = el + }} + /> + +
+ {title} + {badge} +
+
+ +
+ { + const nextChoiceId = v.startsWith('online:') + ? 'online' + : v.startsWith('biblio:') + ? v.slice('biblio:'.length) + : undefined + + if (!nextChoiceId) return + + onPatchSugerencia(s.id, { + biblioteca: { + options, + choiceId: nextChoiceId, + }, + }) + }} + /> +
+
+ + ) + })} + +
+ ) + }, +) + function DatosBasicosManualStep({ draft, refs,