Files
acad-ia-2/src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx
Guillermo Arrieta Medina ea842ee46c close #170: se añadieron validaciones y mejoras en el modal de nueva bibliografía
-Se implementaron restricciones en SugerenciasStep: el campo de búsqueda se limitó a 200 caracteres y la generación quedó bloqueada si hay 20 o más referencias seleccionadas; se añadió tooltip en el botón de generar cuando la query tiene menos de 3 caracteres.
-Se reforzaron validaciones en FormatoYCitasStep y DatosBasicosManualStep: el título se trim-eó y se forzó a no quedar vacío (max 500 caracteres); si un título queda vacío se hace scroll al input/card, se muestra mensaje de error junto al label y se resalta el input; autores se limitó a 2000 caracteres; editorial a 300 caracteres; ISBN a 20 caracteres; el año se convirtió en input numérico permitiendo vacío o un año de 4 dígitos entre 1450 y el año actual +1.
-Se añadieron checkboxes "Año aproximado" y "En prensa" (mutuamente excluyentes): "En prensa" deshabilita el input de año y se marca el estado para citeproc; "Año aproximado" se envía como circa en issued.
-Al generar CSL se incluyeron las propiedades issued.circa y status ('in press') según los flags del ref.
-En ResumenStep se añadieron advertencias por referencia cuando falte autor(es), año (si no está "en prensa"), editorial o ISBN.
-Se corrigieron detalles de UX en edición de autores para preservar saltos de línea y se añadieron handlers para evitar errores de validación al mover entre pasos.
2026-03-11 16:03:05 -06:00

2122 lines
67 KiB
TypeScript

import { useNavigate } from '@tanstack/react-router'
import CSL from 'citeproc'
import {
Globe,
Link as LinkIcon,
Loader2,
Plus,
RefreshCw,
X,
} from 'lucide-react'
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import type { BuscarBibliografiaRequest } from '@/data'
import type {
EndpointResult,
GoogleBooksVolume,
OpenLibraryDoc,
} from '@/data/api/subjects.api'
import type { TablesInsert } from '@/types/supabase'
import { defineStepper } from '@/components/stepper'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { WizardLayout } from '@/components/wizard/WizardLayout'
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
import { buscar_bibliografia } from '@/data'
import { useCreateBibliografia } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils'
type MetodoBibliografia = 'MANUAL' | 'EN_LINEA' | null
export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago'
type IdiomaBibliografia =
| 'ALL'
| 'ES'
| 'EN'
| 'DE'
| 'ZH'
| 'FR'
| 'IT'
| 'JA'
| 'RU'
const IDIOMA_LABEL: Record<IdiomaBibliografia, string> = {
ALL: 'Todos',
ES: 'Español',
EN: 'Inglés',
DE: 'Alemán',
ZH: 'Chino',
FR: 'Francés',
IT: 'Italiano',
JA: 'Japonés',
RU: 'Ruso',
}
const IDIOMA_TO_GOOGLE: Record<IdiomaBibliografia, string | undefined> = {
ALL: undefined,
ES: 'es',
EN: 'en',
DE: 'de',
ZH: 'zh',
FR: 'fr',
IT: 'it',
JA: 'ja',
RU: 'ru',
}
// ISO 639-2 (bibliographic codes) commonly used by Open Library.
const IDIOMA_TO_OPEN_LIBRARY: Record<IdiomaBibliografia, string | undefined> = {
ALL: undefined,
ES: 'spa',
EN: 'eng',
DE: 'ger',
ZH: 'chi',
FR: 'fre',
IT: 'ita',
JA: 'jpn',
RU: 'rus',
}
const MIN_YEAR = 1450
const MAX_YEAR = new Date().getFullYear() + 1
type CSLAuthor = {
family: string
given: string
}
type CSLItem = {
id: string
type: 'book'
title: string
author: Array<CSLAuthor>
publisher?: string
issued?: { 'date-parts': Array<Array<number>>; circa?: boolean }
status?: string
ISBN?: string
}
type BibliografiaAsignaturaInsert = TablesInsert<'bibliografia_asignatura'>
type BibliografiaTipo = BibliografiaAsignaturaInsert['tipo']
type BibliografiaTipoFuente = NonNullable<
BibliografiaAsignaturaInsert['tipo_fuente']
>
type BibliografiaRef = {
id: string
source: BibliografiaTipoFuente
raw?: GoogleBooksVolume | OpenLibraryDoc
title: string
subtitle?: string
authors: Array<string>
publisher?: string
year?: number
yearIsApproximate?: boolean
isInPress?: boolean
isbn?: string
tipo: BibliografiaTipo
}
type WizardState = {
metodo: MetodoBibliografia
ia: {
q: string
idioma: IdiomaBibliografia
showConservacionTooltip: boolean
sugerencias: Array<{
id: string
selected: boolean
endpoint: EndpointResult['endpoint']
item: GoogleBooksVolume | OpenLibraryDoc
}>
isLoading: boolean
errorMessage: string | null
}
manual: {
draft: {
title: string
authorsText: string
publisher: string
yearText: string
isbn: string
}
refs: Array<BibliografiaRef>
}
formato: FormatoCita | null
refs: Array<BibliografiaRef>
citaEdits: Record<FormatoCita, Record<string, string>>
generatingIds: Set<string>
isSaving: boolean
errorMessage: string | null
}
type IASugerencia = WizardState['ia']['sugerencias'][number]
function iaSugerenciaToEndpointResult(s: IASugerencia): EndpointResult {
return s.endpoint === 'google'
? { endpoint: 'google', item: s.item as GoogleBooksVolume }
: { endpoint: 'open_library', item: s.item as OpenLibraryDoc }
}
const Wizard = defineStepper(
{ id: 'metodo', title: 'Método', description: 'Manual o Buscar en línea' },
{
id: 'paso2',
title: 'Datos básicos',
description: 'Seleccionar o capturar',
},
{ id: 'paso3', title: 'Detalles', description: 'Formato y citas' },
{ id: 'resumen', title: 'Resumen', description: 'Confirmar' },
)
function parsearAutor(nombreCompleto: string): CSLAuthor {
if (nombreCompleto.includes(',')) {
return {
family: nombreCompleto.split(',')[0]?.trim() ?? '',
given: nombreCompleto.split(',')[1]?.trim() ?? '',
}
}
const partes = nombreCompleto.trim().split(/\s+/).filter(Boolean)
if (partes.length === 1) return { family: partes[0] ?? '', given: '' }
const family = partes.pop() ?? ''
const given = partes.join(' ')
return { family, given }
}
function tryParseYear(publishedDate?: string): number | undefined {
if (!publishedDate) return undefined
const match = String(publishedDate).match(/\d{4}/)
if (!match) return undefined
const year = Number.parseInt(match[0], 10)
return Number.isFinite(year) ? year : undefined
}
function sanitizeYearInput(value: string): string {
return value.replace(/[^\d]/g, '').slice(0, 4)
}
function tryParseStrictYear(value: string): number | undefined {
const cleaned = sanitizeYearInput(value)
if (!/^\d{4}$/.test(cleaned)) return undefined
const year = Number.parseInt(cleaned, 10)
if (!Number.isFinite(year)) return undefined
if (year < MIN_YEAR || year > MAX_YEAR) return undefined
return year
}
function randomUUID(): string {
try {
const c = (globalThis as any).crypto
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
} catch {
// ignore
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function tryParseYearFromOpenLibrary(doc: OpenLibraryDoc): number | undefined {
const y1 = doc['first_publish_year']
if (typeof y1 === 'number' && Number.isFinite(y1)) return y1
const years = doc['publish_year']
if (Array.isArray(years)) {
const numeric = years
.map((x) => (typeof x === 'number' ? x : Number(x)))
.filter((n) => Number.isFinite(n))
if (numeric.length > 0) return Math.max(...numeric)
}
const published = doc['publish_date']
if (typeof published === 'string') return tryParseYear(published)
return undefined
}
function getEndpointResultId(result: EndpointResult): string {
if (result.endpoint === 'google') {
return `google:${result.item.id}`
}
const doc = result.item
const key = doc['key']
if (typeof key === 'string' && key.trim()) return `open_library:${key}`
const cover = doc['cover_edition_key']
if (typeof cover === 'string' && cover.trim()) return `open_library:${cover}`
const editionKey = doc['edition_key']
if (Array.isArray(editionKey) && typeof editionKey[0] === 'string') {
return `open_library:${editionKey[0]}`
}
return `open_library:${randomUUID()}`
}
function endpointResultToRef(result: EndpointResult): BibliografiaRef {
if (result.endpoint === 'google') {
const volume = result.item
const info = volume.volumeInfo ?? {}
const title = (info.title ?? '').trim() || 'Sin título'
const subtitle =
typeof info.subtitle === 'string' ? info.subtitle.trim() : undefined
const authors = Array.isArray(info.authors) ? info.authors : []
const publisher =
typeof info.publisher === 'string' ? info.publisher : undefined
const year = tryParseYear(info.publishedDate)
const isbn =
info.industryIdentifiers?.find((x) => x.identifier)?.identifier ??
undefined
return {
id: getEndpointResultId(result),
source: 'BIBLIOTECA',
raw: volume,
title,
subtitle,
authors,
publisher,
year,
isbn,
tipo: 'BASICA',
}
}
const doc = result.item
const title =
(typeof doc['title'] === 'string' ? doc['title'] : '').trim() ||
'Sin título'
const subtitle =
typeof doc['subtitle'] === 'string' ? doc['subtitle'].trim() : undefined
const authors = Array.isArray(doc['author_name'])
? (doc['author_name'] as Array<unknown>).filter(
(a): a is string => typeof a === 'string',
)
: []
const publisher = Array.isArray(doc['publisher'])
? (doc['publisher'] as Array<unknown>).find(
(p): p is string => typeof p === 'string',
)
: typeof doc['publisher'] === 'string'
? doc['publisher']
: undefined
const year = tryParseYearFromOpenLibrary(doc)
const isbn = Array.isArray(doc['isbn'])
? (doc['isbn'] as Array<unknown>).find(
(x): x is string => typeof x === 'string',
)
: undefined
return {
id: getEndpointResultId(result),
source: 'BIBLIOTECA',
raw: doc,
title,
subtitle,
authors,
publisher,
year,
isbn,
tipo: 'BASICA',
}
}
function getResultYear(result: EndpointResult): number | undefined {
if (result.endpoint === 'google') {
const info = result.item.volumeInfo ?? {}
return tryParseYear(info.publishedDate)
}
return tryParseYearFromOpenLibrary(result.item)
}
function sortResultsByMostRecent(a: EndpointResult, b: EndpointResult) {
const ya = getResultYear(a)
const yb = getResultYear(b)
if (typeof ya === 'number' && typeof yb === 'number') return yb - ya
if (typeof ya === 'number') return -1
if (typeof yb === 'number') return 1
return 0
}
function citeprocHtmlToPlainText(value: string) {
const input = value
if (!input) return ''
// citeproc suele devolver HTML + entidades (`&#38;`, `&amp;`, etc.).
// Convertimos a texto plano usando el parser del navegador.
try {
const doc = new DOMParser().parseFromString(input, 'text/html')
return (doc.body.textContent || '').replace(/\s+/g, ' ').trim()
} catch {
// Fallback ultra simple (por si DOMParser no existe en algún entorno).
return input
.replace(/<[^>]*>/g, ' ')
.replace(/&#38;?/g, '&')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
}
async function fetchTextCached(url: string, cache: Map<string, string>) {
const cached = cache.get(url)
if (cached) return cached
const res = await fetch(url)
if (!res.ok) throw new Error(`No se pudo cargar recurso: ${url}`)
const text = await res.text()
// En dev (SPA), una ruta inexistente puede devolver `index.html` con 200.
// Eso rompe citeproc con errores poco claros.
const trimmed = text.trim().toLowerCase()
const looksLikeHtml =
trimmed.startsWith('<!doctype html') || trimmed.startsWith('<html')
if (looksLikeHtml) {
throw new Error(
`Recurso CSL/XML no encontrado en ${url}. ` +
`Asegúrate de colocar los archivos en public/csl (ver public/csl/README.md).`,
)
}
const looksLikeXml =
trimmed.startsWith('<?xml') ||
trimmed.startsWith('<style') ||
trimmed.startsWith('<locale')
if (!looksLikeXml) {
throw new Error(
`Recurso en ${url} no parece XML CSL válido. ` +
`Verifica que sea un archivo .csl/.xml correcto.`,
)
}
cache.set(url, text)
return text
}
// Recursos locales servidos desde Vite `public/`.
// Colocar los archivos en `public/csl/styles/*` y `public/csl/locales/*`.
const PUBLIC_BASE_URL = import.meta.env.BASE_URL || '/'
function publicUrl(path: string) {
return `${PUBLIC_BASE_URL}${path.replace(/^\//, '')}`
}
const CSL_STYLE_URL: Record<FormatoCita, string> = {
apa: publicUrl('csl/styles/apa.csl'),
ieee: publicUrl('csl/styles/ieee.csl'),
chicago: publicUrl('csl/styles/chicago-author-date.csl'),
vancouver: publicUrl('csl/styles/nlm-citation-sequence.csl'),
}
const CSL_LOCALE_URL = publicUrl('csl/locales/locales-es-MX.xml')
export function NuevaBibliografiaModalContainer({
planId,
asignaturaId,
}: {
planId: string
asignaturaId: string
}) {
const navigate = useNavigate()
const createBibliografia = useCreateBibliografia()
const formatoStepRef = useRef<FormatoYCitasStepHandle | null>(null)
const [wizard, setWizard] = useState<WizardState>({
metodo: null,
ia: {
q: '',
idioma: 'ALL',
showConservacionTooltip: false,
sugerencias: [],
isLoading: false,
errorMessage: null,
},
manual: {
draft: {
title: '',
authorsText: '',
publisher: '',
yearText: '',
isbn: '',
},
refs: [],
},
formato: null,
refs: [],
citaEdits: {
apa: {},
ieee: {},
chicago: {},
vancouver: {},
},
generatingIds: new Set(),
isSaving: false,
errorMessage: null,
})
const styleCacheRef = useRef(new Map<string, string>())
const localeCacheRef = useRef(new Map<string, string>())
const titleOverrides =
wizard.metodo === 'EN_LINEA'
? { paso2: 'Sugerencias', paso3: 'Estructura' }
: { paso2: 'Datos básicos', paso3: 'Detalles' }
const handleClose = () => {
navigate({
to: `/planes/${planId}/asignaturas/${asignaturaId}/bibliografia/`,
resetScroll: false,
})
}
const refsForStep3: Array<BibliografiaRef> =
wizard.metodo === 'EN_LINEA'
? wizard.ia.sugerencias
.filter((s) => s.selected)
.map((s) => endpointResultToRef(iaSugerenciaToEndpointResult(s)))
: wizard.manual.refs
// Mantener `wizard.refs` como snapshot para pasos 3/4.
useEffect(() => {
setWizard((w) => ({ ...w, refs: refsForStep3 }))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wizard.metodo, wizard.ia.sugerencias, wizard.manual.refs])
const citationsForFormato = useMemo(() => {
if (!wizard.formato) return {}
return wizard.citaEdits[wizard.formato]
}, [wizard.citaEdits, wizard.formato])
const allCitationsReady = useMemo(() => {
if (!wizard.formato) return false
if (wizard.refs.length === 0) return false
const map = wizard.citaEdits[wizard.formato]
return wizard.refs.every(
(r) => typeof map[r.id] === 'string' && map[r.id].trim().length > 0,
)
}, [wizard.citaEdits, wizard.formato, wizard.refs])
const canContinueDesdeMetodo =
wizard.metodo === 'MANUAL' || wizard.metodo === 'EN_LINEA'
const canContinueDesdePaso2 =
wizard.metodo === 'EN_LINEA'
? wizard.ia.sugerencias.some((s) => s.selected)
: wizard.manual.refs.length > 0
const canContinueDesdePaso3 = Boolean(wizard.formato) && allCitationsReady
async function handleBuscarSugerencias() {
const hadNoSugerenciasBefore = wizard.ia.sugerencias.length === 0
if (wizard.ia.sugerencias.filter((s) => s.selected).length >= 20) return
const q = wizard.ia.q.trim()
if (!q) return
setWizard((w) => ({
...w,
ia: {
...w.ia,
// Conservar únicamente las sugerencias seleccionadas.
sugerencias: w.ia.sugerencias.filter((s) => s.selected),
showConservacionTooltip: false,
isLoading: true,
errorMessage: null,
},
errorMessage: null,
}))
try {
const idioma = wizard.ia.idioma
const googleLangRestrict = IDIOMA_TO_GOOGLE[idioma]
const openLibraryLanguage = IDIOMA_TO_OPEN_LIBRARY[idioma]
const google: BuscarBibliografiaRequest['google'] = {
orderBy: 'newest',
startIndex: 0,
}
if (googleLangRestrict) google.langRestrict = googleLangRestrict
const openLibrary: BuscarBibliografiaRequest['openLibrary'] = {
sort: 'new',
page: 1,
}
if (openLibraryLanguage) openLibrary.language = openLibraryLanguage
const req: BuscarBibliografiaRequest = {
searchTerms: { q },
google,
openLibrary,
}
const items = (await buscar_bibliografia(req))
.slice()
.sort(sortResultsByMostRecent)
setWizard((w) => {
const existingById = new Map(w.ia.sugerencias.map((s) => [s.id, s]))
const newOnes = items
.map((r) => ({
id: getEndpointResultId(r),
selected: false,
endpoint: r.endpoint,
item: r.item,
}))
.filter((it) => !existingById.has(it.id))
const merged = [...w.ia.sugerencias, ...newOnes].slice()
merged.sort(
(a, b) =>
sortResultsByMostRecent(
iaSugerenciaToEndpointResult(a),
iaSugerenciaToEndpointResult(b),
) || a.id.localeCompare(b.id),
)
return {
...w,
ia: {
...w.ia,
sugerencias: merged,
showConservacionTooltip:
hadNoSugerenciasBefore && newOnes.length > 0,
isLoading: false,
errorMessage: null,
},
}
})
} catch (e: any) {
setWizard((w) => ({
...w,
ia: {
...w.ia,
isLoading: false,
errorMessage:
typeof e?.message === 'string'
? e.message
: 'Error al buscar bibliografía',
},
}))
}
}
async function generateCitasForFormato(
formato: FormatoCita,
refs: Array<BibliografiaRef>,
options?: {
force?: boolean
},
) {
const force = Boolean(options?.force)
setWizard((w) => {
const nextIds = new Set(w.generatingIds)
refs.forEach((r) => nextIds.add(r.id))
return {
...w,
generatingIds: nextIds,
}
})
try {
const xmlStyle = await fetchTextCached(
CSL_STYLE_URL[formato],
styleCacheRef.current,
)
const xmlLocale = await fetchTextCached(
CSL_LOCALE_URL,
localeCacheRef.current,
)
const cslItems: Record<string, CSLItem> = {}
for (const r of refs) {
const trimmedTitle = r.title.trim()
cslItems[r.id] = {
id: r.id,
type: 'book',
title: trimmedTitle || 'Sin título',
author: r.authors.map(parsearAutor),
publisher: r.publisher,
issued:
r.isInPress || !r.year
? undefined
: {
'date-parts': [[r.year]],
circa: r.yearIsApproximate ? true : undefined,
},
status: r.isInPress ? 'in press' : undefined,
ISBN: r.isbn,
}
}
const sys = {
retrieveLocale: (_lang: string) => xmlLocale,
retrieveItem: (id: string) => cslItems[id],
}
const engine = new CSL.Engine(sys as any, xmlStyle)
engine.updateItems(Object.keys(cslItems))
const result = engine.makeBibliography()
// result[0] contiene los metadatos, result[1] las citas formateadas
const meta = result?.[0] as
| { entry_ids?: Array<Array<string>> }
| undefined
const entries = (result?.[1] ?? []) as Array<string>
const citations: Record<string, string> = {}
// meta.entry_ids es un arreglo de arreglos: [["id-2"], ["id-1"], ...]
const sortedIds = meta?.entry_ids ?? []
for (let i = 0; i < entries.length; i++) {
const id = sortedIds[i]?.[0] // Sacamos el ID real de esta posición
if (!id) continue
const cita = citeprocHtmlToPlainText(entries[i] ?? '')
citations[id] = cita
}
setWizard((w) => {
const nextEdits = { ...w.citaEdits }
const existing = nextEdits[formato]
const merged: Record<string, string> = { ...existing }
for (const id of Object.keys(citations)) {
merged[id] =
force || !merged[id] || merged[id].trim().length === 0
? (citations[id] ?? '')
: merged[id]
}
nextEdits[formato] = merged
const nextIds = new Set(w.generatingIds)
refs.forEach((r) => nextIds.delete(r.id))
return {
...w,
citaEdits: nextEdits,
generatingIds: nextIds,
}
})
} catch (e: any) {
setWizard((w) => {
const nextIds = new Set(w.generatingIds)
refs.forEach((r) => nextIds.delete(r.id))
return {
...w,
generatingIds: nextIds,
errorMessage:
typeof e?.message === 'string'
? e.message
: 'Error al generar citas',
}
})
}
}
useEffect(() => {
if (!wizard.formato) return
if (wizard.refs.length === 0) return
const map = wizard.citaEdits[wizard.formato]
const missing = wizard.refs.some(
(r) => !map[r.id] || map[r.id].trim().length === 0,
)
if (!missing) return
if (wizard.generatingIds.size > 0) return
void generateCitasForFormato(wizard.formato, wizard.refs)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wizard.formato, wizard.refs])
async function handleCreate() {
setWizard((w) => ({ ...w, isSaving: true, errorMessage: null }))
try {
if (!wizard.formato) throw new Error('Selecciona un formato')
const map = wizard.citaEdits[wizard.formato]
if (wizard.refs.length === 0) throw new Error('No hay referencias')
await Promise.all(
wizard.refs.map((r) =>
createBibliografia.mutateAsync({
asignatura_id: asignaturaId,
tipo: r.tipo,
cita: map[r.id] ?? '',
tipo_fuente: r.source,
biblioteca_item_id: null,
}),
),
)
setWizard((w) => ({ ...w, isSaving: false }))
handleClose()
} catch (e: any) {
setWizard((w) => ({
...w,
isSaving: false,
errorMessage:
typeof e?.message === 'string'
? e.message
: 'Error al guardar bibliografía',
}))
}
}
return (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => {
const idx = Wizard.utils.getIndex(methods.current.id)
const isLast = idx >= Wizard.steps.length - 1
return (
<WizardLayout
title="Agregar Bibliografía"
onClose={handleClose}
headerSlot={
<WizardResponsiveHeader
wizard={Wizard}
methods={methods}
titleOverrides={titleOverrides}
/>
}
footerSlot={
<Wizard.Stepper.Controls>
<div className="flex grow items-center justify-between">
<Button
variant="secondary"
onClick={() => methods.prev()}
disabled={
idx === 0 || wizard.ia.isLoading || wizard.isSaving
}
>
Anterior
</Button>
{isLast ? (
<Button onClick={handleCreate} disabled={wizard.isSaving}>
{wizard.isSaving
? 'Agregando...'
: 'Agregar Bibliografía'}
</Button>
) : (
<Button
onClick={() => {
if (idx === 2) {
const ok =
formatoStepRef.current?.validateBeforeNext() ?? true
if (!ok) return
}
methods.next()
}}
disabled={
wizard.ia.isLoading ||
wizard.isSaving ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdePaso2) ||
(idx === 2 && !canContinueDesdePaso3)
}
>
Siguiente
</Button>
)}
</div>
</Wizard.Stepper.Controls>
}
>
<div className="mx-auto max-w-3xl">
{wizard.errorMessage ? (
<Card className="border-destructive/40 mb-4">
<CardHeader>
<CardTitle className="text-destructive">
{wizard.errorMessage}
</CardTitle>
</CardHeader>
</Card>
) : null}
{idx === 0 && (
<Wizard.Stepper.Panel>
<MetodoStep
metodo={wizard.metodo}
onChange={(metodo) =>
setWizard((w) => ({
...w,
metodo,
formato: null,
errorMessage: null,
}))
}
/>
</Wizard.Stepper.Panel>
)}
{idx === 1 && (
<Wizard.Stepper.Panel>
{wizard.metodo === 'EN_LINEA' ? (
<SugerenciasStep
q={wizard.ia.q}
idioma={wizard.ia.idioma}
isLoading={wizard.ia.isLoading}
errorMessage={wizard.ia.errorMessage}
sugerencias={wizard.ia.sugerencias}
showConservacionTooltip={
wizard.ia.showConservacionTooltip
}
onDismissConservacionTooltip={() =>
setWizard((w) => ({
...w,
ia: { ...w.ia, showConservacionTooltip: false },
}))
}
onChange={(patch) =>
setWizard((w) => ({
...w,
ia: {
...w.ia,
...patch,
},
errorMessage: null,
}))
}
onGenerate={handleBuscarSugerencias}
/>
) : (
<DatosBasicosManualStep
draft={wizard.manual.draft}
refs={wizard.manual.refs}
onChangeDraft={(draft) =>
setWizard((w) => ({
...w,
manual: { ...w.manual, draft },
errorMessage: null,
}))
}
onAddRef={(ref) =>
setWizard((w) => ({
...w,
manual: {
...w.manual,
refs: [...w.manual.refs, ref],
},
errorMessage: null,
}))
}
onRemoveRef={(id) =>
setWizard((w) => ({
...w,
manual: {
...w.manual,
refs: w.manual.refs.filter((r) => r.id !== id),
},
}))
}
/>
)}
</Wizard.Stepper.Panel>
)}
{idx === 2 && (
<Wizard.Stepper.Panel>
<FormatoYCitasStep
ref={formatoStepRef}
refs={wizard.refs}
formato={wizard.formato}
citations={citationsForFormato}
generatingIds={wizard.generatingIds}
onChangeFormato={(formato) => {
setWizard((w) => ({ ...w, formato, errorMessage: null }))
if (formato) {
void generateCitasForFormato(formato, wizard.refs)
}
}}
onRegenerate={() => {
if (!wizard.formato) return
void generateCitasForFormato(
wizard.formato,
wizard.refs,
{
force: true,
},
)
}}
onChangeRef={(id, patch) =>
setWizard((w) => ({
...w,
refs: w.refs.map((r) =>
r.id === id ? { ...r, ...patch } : r,
),
}))
}
onChangeTipo={(id, tipo) =>
setWizard((w) => ({
...w,
refs: w.refs.map((r) =>
r.id === id ? { ...r, tipo } : r,
),
}))
}
/>
</Wizard.Stepper.Panel>
)}
{idx === 3 && (
<Wizard.Stepper.Panel>
<ResumenStep
metodo={wizard.metodo}
formato={wizard.formato}
refs={wizard.refs}
citations={
wizard.formato ? wizard.citaEdits[wizard.formato] : {}
}
/>
</Wizard.Stepper.Panel>
)}
</div>
</WizardLayout>
)
}}
</Wizard.Stepper.Provider>
)
}
function MetodoStep({
metodo,
onChange,
}: {
metodo: MetodoBibliografia
onChange: (metodo: MetodoBibliografia) => void
}) {
const isSelected = (m: Exclude<MetodoBibliografia, null>) => metodo === m
return (
<div className="grid gap-4">
<Card
className={cn(
'cursor-pointer transition-all',
isSelected('MANUAL') && 'ring-ring ring-2',
)}
role="button"
tabIndex={0}
onClick={() => onChange('MANUAL')}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="text-primary h-5 w-5" /> Manual
</CardTitle>
<CardDescription>
Captura referencias y edita la cita.
</CardDescription>
</CardHeader>
</Card>
<Card
className={cn(
'cursor-pointer transition-all',
isSelected('EN_LINEA') && 'ring-ring ring-2',
)}
role="button"
tabIndex={0}
onClick={() => onChange('EN_LINEA')}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="text-primary h-5 w-5" /> Buscar en línea
</CardTitle>
<CardDescription>
Busca sugerencias y selecciona las mejores.
</CardDescription>
</CardHeader>
</Card>
</div>
)
}
function SugerenciasStep({
q,
idioma,
isLoading,
errorMessage,
sugerencias,
showConservacionTooltip,
onDismissConservacionTooltip,
onChange,
onGenerate,
}: {
q: string
idioma: IdiomaBibliografia
isLoading: boolean
errorMessage: string | null
sugerencias: Array<{
id: string
selected: boolean
endpoint: EndpointResult['endpoint']
item: GoogleBooksVolume | OpenLibraryDoc
}>
showConservacionTooltip: boolean
onDismissConservacionTooltip: () => void
onChange: (
patch: Partial<{
q: string
idioma: IdiomaBibliografia
sugerencias: any
}>,
) => void
onGenerate: () => void
}) {
const selectedCount = sugerencias.filter((s) => s.selected).length
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Buscar sugerencias</CardTitle>
<CardDescription>
Conserva las seleccionadas y agrega nuevas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2">
<Label>Búsqueda</Label>
<Input
value={q}
maxLength={200}
onChange={(e) => onChange({ q: e.target.value.slice(0, 200) })}
placeholder="Ej: ingeniería de software, bases de datos..."
/>
</div>
<div className="mt-3 flex w-full flex-col items-end justify-between gap-3 sm:flex-row">
<div className="w-full sm:w-56">
<Label className="mb-2 block">Idioma</Label>
<Select
value={idioma}
onValueChange={(v) =>
onChange({ idioma: v as IdiomaBibliografia })
}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona" />
</SelectTrigger>
<SelectContent>
{(Object.keys(IDIOMA_LABEL) as Array<IdiomaBibliografia>).map(
(k) => (
<SelectItem key={k} value={k}>
{IDIOMA_LABEL[k]}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
{!isLoading && q.trim().length < 3 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block">
<Button
type="button"
variant="outline"
onClick={onGenerate}
disabled={true}
className="gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
{sugerencias.length > 0
? 'Generar más sugerencias'
: 'Generar sugerencias'}
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-xs">
<p>El query debe ser de al menos 3 caracteres</p>
</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
variant="outline"
onClick={onGenerate}
disabled={
isLoading || q.trim().length < 3 || selectedCount >= 20
}
className="gap-2"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{sugerencias.length > 0
? 'Generar más sugerencias'
: 'Generar sugerencias'}
</Button>
)}
</div>
{errorMessage ? (
<div className="text-destructive text-sm">{errorMessage}</div>
) : null}
</CardContent>
</Card>
<div className="space-y-2">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-base font-medium">Sugerencias</h3>
<Tooltip open={showConservacionTooltip}>
<TooltipTrigger asChild>
<div className="bg-muted text-foreground inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-semibold">
<span aria-hidden>📌</span>
{selectedCount} seleccionadas
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs">
<div className="flex items-start gap-2">
<span className="flex-1 text-sm">
Al generar más sugerencias, se conservarán las referencias
seleccionadas.
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onDismissConservacionTooltip}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</TooltipContent>
</Tooltip>
</div>
<div className="max-h-96 space-y-1 overflow-y-auto pr-1">
{sugerencias.map((s) => {
const selected = s.selected
const badgeLabel =
s.endpoint === 'google' ? 'Google' : 'Open Library'
const title =
s.endpoint === 'google'
? (
(s.item as GoogleBooksVolume).volumeInfo?.title ??
'Sin título'
).trim()
: (typeof (s.item as OpenLibraryDoc)['title'] === 'string'
? ((s.item as OpenLibraryDoc)['title'] as string)
: 'Sin título'
).trim()
const subtitle =
s.endpoint === 'google'
? (typeof (s.item as GoogleBooksVolume).volumeInfo?.subtitle ===
'string'
? ((s.item as GoogleBooksVolume).volumeInfo
?.subtitle as string)
: ''
).trim()
: (typeof (s.item as OpenLibraryDoc)['subtitle'] === 'string'
? ((s.item as OpenLibraryDoc)['subtitle'] as string)
: ''
).trim()
const browserHref = (() => {
if (s.endpoint === 'google') {
const info = (s.item as GoogleBooksVolume).volumeInfo
const previewLink =
typeof info?.previewLink === 'string'
? info.previewLink
: undefined
const infoLink =
typeof info?.infoLink === 'string' ? info.infoLink : undefined
return previewLink || infoLink
}
const key = (s.item as OpenLibraryDoc)['key']
if (typeof key === 'string' && key.trim()) {
return `https://openlibrary.org/${key}`
}
return undefined
})()
const authors =
s.endpoint === 'google'
? (
(s.item as GoogleBooksVolume).volumeInfo?.authors ?? []
).join(', ')
: Array.isArray((s.item as OpenLibraryDoc)['author_name'])
? (
(s.item as OpenLibraryDoc)[
'author_name'
] as Array<unknown>
)
.filter((a): a is string => typeof a === 'string')
.join(', ')
: ''
const year =
s.endpoint === 'google'
? tryParseYear(
(s.item as GoogleBooksVolume).volumeInfo?.publishedDate,
)
: tryParseYearFromOpenLibrary(s.item as OpenLibraryDoc)
return (
<Label
key={s.id}
aria-checked={selected}
className={cn(
'border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-start 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={selected}
onCheckedChange={(checked) =>
onChange({
sugerencias: sugerencias.map((x) =>
x.id === s.id ? { ...x, selected: !!checked } : x,
),
})
}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 truncate text-sm font-medium">
{title}
</div>
{subtitle ? (
<div className="text-muted-foreground min-w-0 truncate text-xs">
{subtitle}
</div>
) : null}
</div>
<div className="text-muted-foreground text-xs">
{authors || '—'}
{year ? `${year}` : ''}
</div>
<div className="flex justify-between">
<a
href={browserHref}
target="_blank"
rel="noreferrer"
className={cn(
'text-muted-foreground hover:text-primary inline-flex items-center gap-1 text-xs underline transition-colors visited:text-purple-500',
!browserHref && 'invisible',
)}
>
Ver ficha <LinkIcon className="h-3.5 w-3.5" />
</a>
<Badge variant="secondary" className="shrink-0">
{badgeLabel}
</Badge>
</div>
</div>
</Label>
)
})}
</div>
</div>
</div>
)
}
function DatosBasicosManualStep({
draft,
refs,
onChangeDraft,
onAddRef,
onRemoveRef,
}: {
draft: WizardState['manual']['draft']
refs: Array<BibliografiaRef>
onChangeDraft: (draft: WizardState['manual']['draft']) => void
onAddRef: (ref: BibliografiaRef) => void
onRemoveRef: (id: string) => void
}) {
const canAdd = draft.title.trim().length > 0
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Agregar referencia</CardTitle>
<CardDescription>
Captura los datos y agrégala a la lista.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<div className="grid gap-2">
<Label>Título</Label>
<Input
value={draft.title}
maxLength={500}
onChange={(e) =>
onChangeDraft({
...draft,
title: e.target.value.slice(0, 500),
})
}
onBlur={() => {
const trimmed = draft.title.trim()
if (trimmed !== draft.title) {
onChangeDraft({ ...draft, title: trimmed })
}
}}
/>
</div>
<div className="grid gap-2">
<Label>Autores (uno por línea)</Label>
<Textarea
value={draft.authorsText}
maxLength={2000}
onChange={(e) =>
onChangeDraft({
...draft,
authorsText: e.target.value.slice(0, 2000),
})
}
className="min-h-22.5"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<Label>Editorial</Label>
<Input
value={draft.publisher}
onChange={(e) =>
onChangeDraft({
...draft,
publisher: e.target.value.slice(0, 300),
})
}
maxLength={300}
/>
</div>
<div className="grid gap-2">
<Label>Año</Label>
<Input
value={draft.yearText}
onChange={(e) =>
onChangeDraft({
...draft,
yearText: sanitizeYearInput(e.target.value),
})
}
onBlur={() => {
if (!draft.yearText) return
if (!tryParseStrictYear(draft.yearText)) {
onChangeDraft({ ...draft, yearText: '' })
}
}}
type="number"
inputMode="numeric"
step={1}
min={MIN_YEAR}
max={MAX_YEAR}
placeholder={(MAX_YEAR - 1).toString()}
/>
</div>
</div>
<div className="grid gap-2">
<Label>ISBN</Label>
<Input
value={draft.isbn}
onChange={(e) =>
onChangeDraft({
...draft,
isbn: e.target.value.slice(0, 20),
})
}
maxLength={20}
/>
</div>
<Button
type="button"
disabled={!canAdd}
onClick={() => {
const year = tryParseStrictYear(draft.yearText)
const title = draft.title.trim()
if (!title) return
const ref: BibliografiaRef = {
id: `manual-${randomUUID()}`,
source: 'MANUAL',
title,
authors: draft.authorsText
.split(/\r?\n/)
.map((x) => x.trim())
.filter(Boolean),
publisher: draft.publisher.trim() || undefined,
year,
isbn: draft.isbn.trim() || undefined,
tipo: 'BASICA',
}
onAddRef(ref)
onChangeDraft({
title: '',
authorsText: '',
publisher: '',
yearText: '',
isbn: '',
})
}}
>
<Plus className="mr-2 h-4 w-4" /> Agregar a la lista
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Referencias</CardTitle>
<CardDescription>{refs.length} en total</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{refs.map((r) => (
<div
key={r.id}
className="flex items-start justify-between gap-3 rounded-lg border bg-white p-3"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{r.title}</div>
<div className="text-muted-foreground text-xs">
{r.authors.join(', ') || '—'}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemoveRef(r.id)}
>
Quitar
</Button>
</div>
))}
</CardContent>
</Card>
</div>
)
}
type FormatoYCitasStepHandle = {
validateBeforeNext: () => boolean
}
type FormatoYCitasStepProps = {
refs: Array<BibliografiaRef>
formato: FormatoCita | null
citations: Record<string, string>
generatingIds: Set<string>
onChangeFormato: (formato: FormatoCita | null) => void
onRegenerate: () => void
onChangeRef: (id: string, patch: Partial<BibliografiaRef>) => void
onChangeTipo: (id: string, tipo: BibliografiaTipo) => void
}
const FormatoYCitasStep = forwardRef<
FormatoYCitasStepHandle,
FormatoYCitasStepProps
>(function FormatoYCitasStep(
{
refs,
formato,
citations,
generatingIds,
onChangeFormato,
onRegenerate,
onChangeRef,
onChangeTipo,
},
ref,
) {
const isGeneratingAny = generatingIds.size > 0
const [authorsDraftById, setAuthorsDraftById] = useState<
Record<string, string>
>({})
const [yearDraftById, setYearDraftById] = useState<Record<string, string>>({})
const [titleErrorsById, setTitleErrorsById] = useState<
Record<string, string>
>({})
const titleInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
const scrollToTitle = (id: string) => {
const el = titleInputRefs.current[id]
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.focus()
}
useEffect(() => {
const ids = new Set(refs.map((r) => r.id))
setAuthorsDraftById((prev) => {
let next = prev
for (const id of Object.keys(prev)) {
if (!ids.has(id)) {
if (next === prev) next = { ...prev }
delete next[id]
}
}
for (const r of refs) {
if (typeof next[r.id] !== 'string') {
if (next === prev) next = { ...prev }
next[r.id] = r.authors.join('\n')
}
}
return next
})
setYearDraftById((prev) => {
let next = prev
for (const id of Object.keys(prev)) {
if (!ids.has(id)) {
if (next === prev) next = { ...prev }
delete next[id]
}
}
for (const r of refs) {
if (typeof next[r.id] !== 'string') {
if (next === prev) next = { ...prev }
next[r.id] = r.isInPress
? ''
: typeof r.year === 'number'
? String(r.year)
: ''
}
}
return next
})
}, [refs])
const validateBeforeNext = () => {
const nextErrors: Record<string, string> = {}
let firstInvalidId: string | undefined
for (const r of refs) {
const trimmed = r.title.trim()
if (r.title !== trimmed) onChangeRef(r.id, { title: trimmed })
if (!trimmed) {
nextErrors[r.id] = 'El título es requerido'
if (!firstInvalidId) firstInvalidId = r.id
}
}
setTitleErrorsById(nextErrors)
if (firstInvalidId) {
scrollToTitle(firstInvalidId)
return false
}
return true
}
useImperativeHandle(ref, () => ({ validateBeforeNext }))
return (
<div className="space-y-6">
<div className="bg-muted/40 border-border sticky top-0 z-10 rounded-lg border p-4 backdrop-blur-md">
<div className="flex flex-col items-end justify-between gap-4 sm:flex-row">
<div className="w-full flex-1 space-y-1.5 sm:max-w-xs">
<Label className="text-muted-foreground text-xs tracking-wider uppercase">
Formato de citación
</Label>
<Select
value={formato ?? ''}
onValueChange={(v) => onChangeFormato(v as any)}
>
<SelectTrigger className="bg-background">
<SelectValue placeholder="Seleccionar…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apa">APA</SelectItem>
<SelectItem value="ieee">IEEE</SelectItem>
<SelectItem value="chicago">Chicago</SelectItem>
<SelectItem value="vancouver">Vancouver</SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="secondary"
className="w-full gap-2 sm:w-auto"
onClick={onRegenerate}
disabled={!formato || refs.length === 0 || isGeneratingAny}
>
<RefreshCw className="h-4 w-4" /> Regenerar citas
</Button>
</div>
</div>
<div className="space-y-4">
<div className="space-y-3">
{refs.map((r) => {
const isGenerating = generatingIds.has(r.id)
const titleError = titleErrorsById[r.id]
const authorsText = authorsDraftById[r.id] ?? r.authors.join('\n')
const yearText = r.isInPress
? ''
: (yearDraftById[r.id] ??
(typeof r.year === 'number' ? String(r.year) : ''))
const isbnText = r.isbn ?? ''
const publisherText = r.publisher ?? ''
return (
<Card key={r.id} className="overflow-hidden">
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="space-y-2 sm:col-span-9">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">Título</Label>
{titleError ? (
<span className="text-destructive text-xs">
{titleError}
</span>
) : null}
</div>
<Input
ref={(el) => {
titleInputRefs.current[r.id] = el
}}
value={r.title}
maxLength={500}
aria-invalid={Boolean(titleError)}
className={cn(
titleError &&
'border-destructive focus-visible:ring-destructive',
)}
disabled={isGeneratingAny || isGenerating}
onChange={(e) => {
const nextRaw = e.currentTarget.value.slice(0, 500)
const wasNonEmpty = r.title.trim().length > 0
const isEmptyNow = nextRaw.trim().length === 0
onChangeRef(r.id, { title: nextRaw })
if (isEmptyNow) {
setTitleErrorsById((prev) => ({
...prev,
[r.id]: 'El título es requerido',
}))
if (wasNonEmpty) scrollToTitle(r.id)
} else {
setTitleErrorsById((prev) => {
if (!prev[r.id]) return prev
const next = { ...prev }
delete next[r.id]
return next
})
}
}}
onBlur={() => {
const trimmed = r.title.trim()
if (trimmed !== r.title)
onChangeRef(r.id, { title: trimmed })
if (!trimmed) {
setTitleErrorsById((prev) => ({
...prev,
[r.id]: 'El título es requerido',
}))
}
}}
/>
</div>
<div className="flex w-full flex-col items-start gap-2 sm:col-span-3 sm:items-stretch">
<Label className="text-xs">Tipo</Label>
<Select
value={r.tipo}
onValueChange={(v) => onChangeTipo(r.id, v as any)}
disabled={isGenerating || isGeneratingAny}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BASICA">Básica</SelectItem>
<SelectItem value="COMPLEMENTARIA">
Complementaria
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="space-y-2 sm:col-span-6">
<Label className="text-xs">Autores (uno por línea)</Label>
<Textarea
value={authorsText}
maxLength={2000}
disabled={isGeneratingAny || isGenerating}
className="min-h-22.5"
onChange={(e) => {
const nextText = e.currentTarget.value.slice(0, 2000)
setAuthorsDraftById((prev) => ({
...prev,
[r.id]: nextText,
}))
onChangeRef(r.id, {
authors: nextText
.split(/\r?\n/)
.map((x) => x.trim())
.filter(Boolean),
})
}}
/>
</div>
<div className="space-y-2 sm:col-span-6">
<Label className="text-xs">Editorial</Label>
<Input
value={publisherText}
maxLength={300}
disabled={isGeneratingAny || isGenerating}
onChange={(e) => {
const raw = e.currentTarget.value.slice(0, 300)
onChangeRef(r.id, {
publisher: raw.trim() || undefined,
})
}}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="space-y-2 sm:col-span-3">
<Label className="text-xs">Año</Label>
<Input
type="number"
inputMode="numeric"
step={1}
min={MIN_YEAR}
max={MAX_YEAR}
value={yearText}
disabled={
isGeneratingAny ||
isGenerating ||
Boolean(r.isInPress)
}
placeholder={(MAX_YEAR - 1).toString()}
onChange={(e) => {
const next = sanitizeYearInput(e.currentTarget.value)
setYearDraftById((prev) => ({
...prev,
[r.id]: next,
}))
const year = tryParseStrictYear(next)
onChangeRef(r.id, {
year,
})
}}
onBlur={() => {
const current = yearDraftById[r.id] ?? ''
if (current.length === 0) return
const parsed = tryParseStrictYear(current)
if (!parsed) {
setYearDraftById((prev) => ({
...prev,
[r.id]: '',
}))
onChangeRef(r.id, { year: undefined })
}
}}
/>
</div>
<div className="space-y-2 sm:col-span-3">
<div className="flex items-center gap-2">
<Checkbox
checked={Boolean(r.yearIsApproximate)}
disabled={isGeneratingAny || isGenerating}
onCheckedChange={(checked) => {
const nextChecked = Boolean(checked)
onChangeRef(r.id, {
yearIsApproximate: nextChecked,
isInPress: nextChecked ? false : r.isInPress,
})
}}
/>
<span className="text-xs">Año aproximado</span>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={Boolean(r.isInPress)}
disabled={isGeneratingAny || isGenerating}
onCheckedChange={(checked) => {
const nextChecked = Boolean(checked)
onChangeRef(r.id, {
isInPress: nextChecked,
yearIsApproximate: nextChecked
? false
: r.yearIsApproximate,
year: nextChecked ? undefined : r.year,
})
if (nextChecked) {
setYearDraftById((prev) => ({
...prev,
[r.id]: '',
}))
}
}}
/>
<span className="text-xs">En prensa</span>
</div>
</div>
<div className="space-y-2 sm:col-span-6">
<Label className="text-xs">ISBN</Label>
<Input
value={isbnText}
maxLength={20}
disabled={isGeneratingAny || isGenerating}
onChange={(e) => {
const next = e.currentTarget.value.slice(0, 20)
onChangeRef(r.id, {
isbn: next.trim() || undefined,
})
}}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Cita generada</Label>
<div className="bg-muted/30 border-border rounded-md border px-3 py-2 text-sm">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
{citations[r.id] ? (
<p className="wrap-break-word">{citations[r.id]}</p>
) : (
<p className="text-muted-foreground">
Cita generada
</p>
)}
</div>
{isGenerating ? (
<Loader2 className="text-muted-foreground mt-0.5 h-4 w-4 animate-spin" />
) : null}
</div>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
</div>
)
})
function ResumenStep({
metodo,
formato,
refs,
citations,
}: {
metodo: MetodoBibliografia
formato: FormatoCita | null
refs: Array<BibliografiaRef>
citations: Record<string, string>
}) {
// 1. Separar las referencias
const basicas = refs.filter((r) => r.tipo === 'BASICA')
const complementarias = refs.filter((r) => r.tipo === 'COMPLEMENTARIA')
const metodoLabel =
metodo === 'MANUAL'
? 'Manual'
: metodo === 'EN_LINEA'
? 'Buscar en línea'
: '—'
return (
<div className="space-y-8">
{/* Panel de Resumen General */}
<div className="bg-muted/40 rounded-lg border p-4">
<h3 className="text-foreground mb-4 text-sm font-semibold">
Resumen de importación
</h3>
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div>
<p className="text-muted-foreground text-xs uppercase">Método</p>
<p className="font-medium">{metodoLabel}</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase">Formato</p>
<p className="font-medium uppercase">{formato ?? '—'}</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase">Básicas</p>
<p className="font-medium">{basicas.length}</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase">
Complementarias
</p>
<p className="font-medium">{complementarias.length}</p>
</div>
</div>
</div>
{/* Sección: Bibliografía Básica */}
{basicas.length > 0 && (
<div className="space-y-3">
<h4 className="text-foreground border-b pb-2 text-sm font-medium">
Bibliografía Básica
</h4>
<div className="space-y-2">
{basicas.map((r) => (
<div
key={r.id}
className="bg-background rounded-md border p-3 text-sm shadow-sm"
>
{(() => {
const warnings = [
r.authors.length === 0 ? 'Falta autor(es)' : null,
!r.isInPress && !r.year ? 'Falta año' : null,
!r.publisher ? 'Falta editorial' : null,
!r.isbn ? 'Falta ISBN' : null,
].filter(Boolean) as Array<string>
return (
<>
<div className="mb-1 flex min-w-0 items-baseline gap-2">
<p className="min-w-0 truncate font-medium">
{r.title}
</p>
{r.subtitle ? (
<p className="text-muted-foreground min-w-0 truncate text-xs">
{r.subtitle}
</p>
) : null}
</div>
<p className="text-muted-foreground">
{citations[r.id] ?? 'Sin cita generada'}
</p>
{warnings.length > 0 ? (
<div className="mt-2 space-y-1">
{warnings.map((w) => (
<p key={w} className="text-destructive text-xs">
{w}
</p>
))}
</div>
) : null}
</>
)
})()}
</div>
))}
</div>
</div>
)}
{/* Sección: Bibliografía Complementaria */}
{complementarias.length > 0 && (
<div className="space-y-3">
<h4 className="text-foreground border-b pb-2 text-sm font-medium">
Bibliografía Complementaria
</h4>
<div className="space-y-2">
{complementarias.map((r) => (
<div
key={r.id}
className="bg-background rounded-md border p-3 text-sm shadow-sm"
>
{(() => {
const warnings = [
r.authors.length === 0 ? 'Falta autor(es)' : null,
!r.isInPress && !r.year ? 'Falta año' : null,
!r.publisher ? 'Falta editorial' : null,
!r.isbn ? 'Falta ISBN' : null,
].filter(Boolean) as Array<string>
return (
<>
<div className="mb-1 flex min-w-0 items-baseline gap-2">
<p className="min-w-0 truncate font-medium">
{r.title}
</p>
{r.subtitle ? (
<p className="text-muted-foreground min-w-0 truncate text-xs">
{r.subtitle}
</p>
) : null}
</div>
<p className="text-muted-foreground">
{citations[r.id] ?? 'Sin cita generada'}
</p>
{warnings.length > 0 ? (
<div className="mt-2 space-y-1">
{warnings.map((w) => (
<p key={w} className="text-destructive text-xs">
{w}
</p>
))}
</div>
) : null}
</>
)
})()}
</div>
))}
</div>
</div>
)}
</div>
)
}