Merge pull request 'Se añadieron validaciones y mejoras en el modal de nueva bibliografía, incluida la validacion de al menos tres caracteres para el query' (#171) from issue/170-validacin-de-mnimo-3-caracteres-en-la-bsqueda-de-b into main

Reviewed-on: #171
This commit was merged in pull request #171.
This commit is contained in:
2026-03-11 22:04:26 +00:00

View File

@@ -8,7 +8,14 @@ import {
RefreshCw, RefreshCw,
X, X,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react' import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import type { BuscarBibliografiaRequest } from '@/data' import type { BuscarBibliografiaRequest } from '@/data'
import type { import type {
@@ -50,7 +57,7 @@ import { buscar_bibliografia } from '@/data'
import { useCreateBibliografia } from '@/data/hooks/useSubjects' import { useCreateBibliografia } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type MetodoBibliografia = 'MANUAL' | 'IA' | null type MetodoBibliografia = 'MANUAL' | 'EN_LINEA' | null
export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago' export type FormatoCita = 'apa' | 'ieee' | 'vancouver' | 'chicago'
type IdiomaBibliografia = type IdiomaBibliografia =
@@ -101,6 +108,9 @@ const IDIOMA_TO_OPEN_LIBRARY: Record<IdiomaBibliografia, string | undefined> = {
RU: 'rus', RU: 'rus',
} }
const MIN_YEAR = 1450
const MAX_YEAR = new Date().getFullYear() + 1
type CSLAuthor = { type CSLAuthor = {
family: string family: string
given: string given: string
@@ -112,7 +122,8 @@ type CSLItem = {
title: string title: string
author: Array<CSLAuthor> author: Array<CSLAuthor>
publisher?: string publisher?: string
issued?: { 'date-parts': Array<Array<number>> } issued?: { 'date-parts': Array<Array<number>>; circa?: boolean }
status?: string
ISBN?: string ISBN?: string
} }
@@ -131,6 +142,8 @@ type BibliografiaRef = {
authors: Array<string> authors: Array<string>
publisher?: string publisher?: string
year?: number year?: number
yearIsApproximate?: boolean
isInPress?: boolean
isbn?: string isbn?: string
tipo: BibliografiaTipo tipo: BibliografiaTipo
@@ -209,6 +222,19 @@ function tryParseYear(publishedDate?: string): number | undefined {
return Number.isFinite(year) ? year : undefined 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 { function randomUUID(): string {
try { try {
const c = (globalThis as any).crypto const c = (globalThis as any).crypto
@@ -425,6 +451,8 @@ export function NuevaBibliografiaModalContainer({
const navigate = useNavigate() const navigate = useNavigate()
const createBibliografia = useCreateBibliografia() const createBibliografia = useCreateBibliografia()
const formatoStepRef = useRef<FormatoYCitasStepHandle | null>(null)
const [wizard, setWizard] = useState<WizardState>({ const [wizard, setWizard] = useState<WizardState>({
metodo: null, metodo: null,
ia: { ia: {
@@ -462,7 +490,7 @@ export function NuevaBibliografiaModalContainer({
const localeCacheRef = useRef(new Map<string, string>()) const localeCacheRef = useRef(new Map<string, string>())
const titleOverrides = const titleOverrides =
wizard.metodo === 'IA' wizard.metodo === 'EN_LINEA'
? { paso2: 'Sugerencias', paso3: 'Estructura' } ? { paso2: 'Sugerencias', paso3: 'Estructura' }
: { paso2: 'Datos básicos', paso3: 'Detalles' } : { paso2: 'Datos básicos', paso3: 'Detalles' }
@@ -474,7 +502,7 @@ export function NuevaBibliografiaModalContainer({
} }
const refsForStep3: Array<BibliografiaRef> = const refsForStep3: Array<BibliografiaRef> =
wizard.metodo === 'IA' wizard.metodo === 'EN_LINEA'
? wizard.ia.sugerencias ? wizard.ia.sugerencias
.filter((s) => s.selected) .filter((s) => s.selected)
.map((s) => endpointResultToRef(iaSugerenciaToEndpointResult(s))) .map((s) => endpointResultToRef(iaSugerenciaToEndpointResult(s)))
@@ -501,10 +529,10 @@ export function NuevaBibliografiaModalContainer({
}, [wizard.citaEdits, wizard.formato, wizard.refs]) }, [wizard.citaEdits, wizard.formato, wizard.refs])
const canContinueDesdeMetodo = const canContinueDesdeMetodo =
wizard.metodo === 'MANUAL' || wizard.metodo === 'IA' wizard.metodo === 'MANUAL' || wizard.metodo === 'EN_LINEA'
const canContinueDesdePaso2 = const canContinueDesdePaso2 =
wizard.metodo === 'IA' wizard.metodo === 'EN_LINEA'
? wizard.ia.sugerencias.some((s) => s.selected) ? wizard.ia.sugerencias.some((s) => s.selected)
: wizard.manual.refs.length > 0 : wizard.manual.refs.length > 0
@@ -513,6 +541,8 @@ export function NuevaBibliografiaModalContainer({
async function handleBuscarSugerencias() { async function handleBuscarSugerencias() {
const hadNoSugerenciasBefore = wizard.ia.sugerencias.length === 0 const hadNoSugerenciasBefore = wizard.ia.sugerencias.length === 0
if (wizard.ia.sugerencias.filter((s) => s.selected).length >= 20) return
const q = wizard.ia.q.trim() const q = wizard.ia.q.trim()
if (!q) return if (!q) return
@@ -633,13 +663,21 @@ export function NuevaBibliografiaModalContainer({
const cslItems: Record<string, CSLItem> = {} const cslItems: Record<string, CSLItem> = {}
for (const r of refs) { for (const r of refs) {
const trimmedTitle = r.title.trim()
cslItems[r.id] = { cslItems[r.id] = {
id: r.id, id: r.id,
type: 'book', type: 'book',
title: r.title || 'Sin título', title: trimmedTitle || 'Sin título',
author: r.authors.map(parsearAutor), author: r.authors.map(parsearAutor),
publisher: r.publisher, publisher: r.publisher,
issued: r.year ? { 'date-parts': [[r.year]] } : undefined, issued:
r.isInPress || !r.year
? undefined
: {
'date-parts': [[r.year]],
circa: r.yearIsApproximate ? true : undefined,
},
status: r.isInPress ? 'in press' : undefined,
ISBN: r.isbn, ISBN: r.isbn,
} }
} }
@@ -797,7 +835,14 @@ export function NuevaBibliografiaModalContainer({
</Button> </Button>
) : ( ) : (
<Button <Button
onClick={() => methods.next()} onClick={() => {
if (idx === 2) {
const ok =
formatoStepRef.current?.validateBeforeNext() ?? true
if (!ok) return
}
methods.next()
}}
disabled={ disabled={
wizard.ia.isLoading || wizard.ia.isLoading ||
wizard.isSaving || wizard.isSaving ||
@@ -842,7 +887,7 @@ export function NuevaBibliografiaModalContainer({
{idx === 1 && ( {idx === 1 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
{wizard.metodo === 'IA' ? ( {wizard.metodo === 'EN_LINEA' ? (
<SugerenciasStep <SugerenciasStep
q={wizard.ia.q} q={wizard.ia.q}
idioma={wizard.ia.idioma} idioma={wizard.ia.idioma}
@@ -908,6 +953,7 @@ export function NuevaBibliografiaModalContainer({
{idx === 2 && ( {idx === 2 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<FormatoYCitasStep <FormatoYCitasStep
ref={formatoStepRef}
refs={wizard.refs} refs={wizard.refs}
formato={wizard.formato} formato={wizard.formato}
citations={citationsForFormato} citations={citationsForFormato}
@@ -1001,11 +1047,11 @@ function MetodoStep({
<Card <Card
className={cn( className={cn(
'cursor-pointer transition-all', 'cursor-pointer transition-all',
isSelected('IA') && 'ring-ring ring-2', isSelected('EN_LINEA') && 'ring-ring ring-2',
)} )}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => onChange('IA')} onClick={() => onChange('EN_LINEA')}
> >
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -1068,7 +1114,8 @@ function SugerenciasStep({
<Label>Búsqueda</Label> <Label>Búsqueda</Label>
<Input <Input
value={q} value={q}
onChange={(e) => onChange({ q: e.target.value })} maxLength={200}
onChange={(e) => onChange({ q: e.target.value.slice(0, 200) })}
placeholder="Ej: ingeniería de software, bases de datos..." placeholder="Ej: ingeniería de software, bases de datos..."
/> />
</div> </div>
@@ -1097,22 +1144,48 @@ function SugerenciasStep({
</Select> </Select>
</div> </div>
<Button {!isLoading && q.trim().length < 3 ? (
type="button" <Tooltip>
variant="outline" <TooltipTrigger asChild>
onClick={onGenerate} <span className="inline-block">
disabled={isLoading || q.trim().length === 0} <Button
className="gap-2" type="button"
> variant="outline"
{isLoading ? ( onClick={onGenerate}
<Loader2 className="h-4 w-4 animate-spin" /> disabled={true}
) : ( className="gap-2"
<RefreshCw className="h-3.5 w-3.5" /> >
)} <RefreshCw className="h-3.5 w-3.5" />
{sugerencias.length > 0 {sugerencias.length > 0
? 'Generar más sugerencias' ? 'Generar más sugerencias'
: 'Generar sugerencias'} : 'Generar sugerencias'}
</Button> </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> </div>
{errorMessage ? ( {errorMessage ? (
@@ -1310,9 +1383,19 @@ function DatosBasicosManualStep({
<Label>Título</Label> <Label>Título</Label>
<Input <Input
value={draft.title} value={draft.title}
maxLength={500}
onChange={(e) => onChange={(e) =>
onChangeDraft({ ...draft, title: e.target.value }) 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>
@@ -1320,8 +1403,12 @@ function DatosBasicosManualStep({
<Label>Autores (uno por línea)</Label> <Label>Autores (uno por línea)</Label>
<Textarea <Textarea
value={draft.authorsText} value={draft.authorsText}
maxLength={2000}
onChange={(e) => onChange={(e) =>
onChangeDraft({ ...draft, authorsText: e.target.value }) onChangeDraft({
...draft,
authorsText: e.target.value.slice(0, 2000),
})
} }
className="min-h-22.5" className="min-h-22.5"
/> />
@@ -1333,8 +1420,12 @@ function DatosBasicosManualStep({
<Input <Input
value={draft.publisher} value={draft.publisher}
onChange={(e) => onChange={(e) =>
onChangeDraft({ ...draft, publisher: e.target.value }) onChangeDraft({
...draft,
publisher: e.target.value.slice(0, 300),
})
} }
maxLength={300}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -1342,9 +1433,23 @@ function DatosBasicosManualStep({
<Input <Input
value={draft.yearText} value={draft.yearText}
onChange={(e) => onChange={(e) =>
onChangeDraft({ ...draft, yearText: e.target.value }) onChangeDraft({
...draft,
yearText: sanitizeYearInput(e.target.value),
})
} }
placeholder="2024" 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> </div>
@@ -1354,8 +1459,12 @@ function DatosBasicosManualStep({
<Input <Input
value={draft.isbn} value={draft.isbn}
onChange={(e) => onChange={(e) =>
onChangeDraft({ ...draft, isbn: e.target.value }) onChangeDraft({
...draft,
isbn: e.target.value.slice(0, 20),
})
} }
maxLength={20}
/> />
</div> </div>
@@ -1363,17 +1472,20 @@ function DatosBasicosManualStep({
type="button" type="button"
disabled={!canAdd} disabled={!canAdd}
onClick={() => { onClick={() => {
const year = Number.parseInt(draft.yearText.trim(), 10) const year = tryParseStrictYear(draft.yearText)
const title = draft.title.trim()
if (!title) return
const ref: BibliografiaRef = { const ref: BibliografiaRef = {
id: `manual-${randomUUID()}`, id: `manual-${randomUUID()}`,
source: 'MANUAL', source: 'MANUAL',
title: draft.title.trim(), title,
authors: draft.authorsText authors: draft.authorsText
.split(/\r?\n/) .split(/\r?\n/)
.map((x) => x.trim()) .map((x) => x.trim())
.filter(Boolean), .filter(Boolean),
publisher: draft.publisher.trim() || undefined, publisher: draft.publisher.trim() || undefined,
year: Number.isFinite(year) ? year : undefined, year,
isbn: draft.isbn.trim() || undefined, isbn: draft.isbn.trim() || undefined,
tipo: 'BASICA', tipo: 'BASICA',
} }
@@ -1425,16 +1537,11 @@ function DatosBasicosManualStep({
) )
} }
function FormatoYCitasStep({ type FormatoYCitasStepHandle = {
refs, validateBeforeNext: () => boolean
formato, }
citations,
generatingIds, type FormatoYCitasStepProps = {
onChangeFormato,
onRegenerate,
onChangeRef,
onChangeTipo,
}: {
refs: Array<BibliografiaRef> refs: Array<BibliografiaRef>
formato: FormatoCita | null formato: FormatoCita | null
citations: Record<string, string> citations: Record<string, string>
@@ -1443,18 +1550,47 @@ function FormatoYCitasStep({
onRegenerate: () => void onRegenerate: () => void
onChangeRef: (id: string, patch: Partial<BibliografiaRef>) => void onChangeRef: (id: string, patch: Partial<BibliografiaRef>) => void
onChangeTipo: (id: string, tipo: BibliografiaTipo) => 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 isGeneratingAny = generatingIds.size > 0
const [authorsDraftById, setAuthorsDraftById] = useState< const [authorsDraftById, setAuthorsDraftById] = useState<
Record<string, string> 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(() => { useEffect(() => {
const ids = new Set(refs.map((r) => r.id)) const ids = new Set(refs.map((r) => r.id))
setAuthorsDraftById((prev) => { setAuthorsDraftById((prev) => {
let next = prev let next = prev
// Remove drafts for refs that no longer exist
for (const id of Object.keys(prev)) { for (const id of Object.keys(prev)) {
if (!ids.has(id)) { if (!ids.has(id)) {
if (next === prev) next = { ...prev } if (next === prev) next = { ...prev }
@@ -1462,7 +1598,6 @@ function FormatoYCitasStep({
} }
} }
// Initialize drafts for new refs (do not override existing edits)
for (const r of refs) { for (const r of refs) {
if (typeof next[r.id] !== 'string') { if (typeof next[r.id] !== 'string') {
if (next === prev) next = { ...prev } if (next === prev) next = { ...prev }
@@ -1472,11 +1607,60 @@ function FormatoYCitasStep({
return next 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]) }, [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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 1. SECCIÓN DE CONTROLES: Sutil, compacta y sticky */}
<div className="bg-muted/40 border-border sticky top-0 z-10 rounded-lg border p-4 backdrop-blur-md"> <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="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"> <div className="w-full flex-1 space-y-1.5 sm:max-w-xs">
@@ -1501,7 +1685,7 @@ function FormatoYCitasStep({
<Button <Button
type="button" type="button"
variant="secondary" // Cambiado a secondary para menor peso visual variant="secondary"
className="w-full gap-2 sm:w-auto" className="w-full gap-2 sm:w-auto"
onClick={onRegenerate} onClick={onRegenerate}
disabled={!formato || refs.length === 0 || isGeneratingAny} disabled={!formato || refs.length === 0 || isGeneratingAny}
@@ -1511,25 +1695,17 @@ function FormatoYCitasStep({
</div> </div>
</div> </div>
{/* 2. SECCIÓN DE LISTA: Separación visual clara */}
<div className="space-y-4"> <div className="space-y-4">
{/* {refs.length > 0 && (
<div className="flex items-center gap-2">
<h3 className="text-muted-foreground text-sm font-medium">
Referencias añadidas
</h3>
<Badge variant="secondary" className="text-xs">
{refs.length}
</Badge>
</div>
)} */}
<div className="space-y-3"> <div className="space-y-3">
{refs.map((r) => { {refs.map((r) => {
const isGenerating = generatingIds.has(r.id) const isGenerating = generatingIds.has(r.id)
const titleError = titleErrorsById[r.id]
const authorsText = authorsDraftById[r.id] ?? r.authors.join('\n') const authorsText = authorsDraftById[r.id] ?? r.authors.join('\n')
const yearText = typeof r.year === 'number' ? String(r.year) : '' const yearText = r.isInPress
? ''
: (yearDraftById[r.id] ??
(typeof r.year === 'number' ? String(r.year) : ''))
const isbnText = r.isbn ?? '' const isbnText = r.isbn ?? ''
const publisherText = r.publisher ?? '' const publisherText = r.publisher ?? ''
@@ -1538,15 +1714,59 @@ function FormatoYCitasStep({
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="space-y-2 sm:col-span-9"> <div className="space-y-2 sm:col-span-9">
<Label className="text-xs">Título</Label> <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 <Input
ref={(el) => {
titleInputRefs.current[r.id] = el
}}
value={r.title} value={r.title}
maxLength={500}
aria-invalid={Boolean(titleError)}
className={cn(
titleError &&
'border-destructive focus-visible:ring-destructive',
)}
disabled={isGeneratingAny || isGenerating} disabled={isGeneratingAny || isGenerating}
onChange={(e) => onChange={(e) => {
onChangeRef(r.id, { const nextRaw = e.currentTarget.value.slice(0, 500)
title: e.currentTarget.value, 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>
@@ -1575,10 +1795,11 @@ function FormatoYCitasStep({
<Label className="text-xs">Autores (uno por línea)</Label> <Label className="text-xs">Autores (uno por línea)</Label>
<Textarea <Textarea
value={authorsText} value={authorsText}
maxLength={2000}
disabled={isGeneratingAny || isGenerating} disabled={isGeneratingAny || isGenerating}
className="min-h-22.5" className="min-h-22.5"
onChange={(e) => { onChange={(e) => {
const nextText = e.currentTarget.value const nextText = e.currentTarget.value.slice(0, 2000)
setAuthorsDraftById((prev) => ({ setAuthorsDraftById((prev) => ({
...prev, ...prev,
[r.id]: nextText, [r.id]: nextText,
@@ -1598,13 +1819,14 @@ function FormatoYCitasStep({
<Label className="text-xs">Editorial</Label> <Label className="text-xs">Editorial</Label>
<Input <Input
value={publisherText} value={publisherText}
maxLength={300}
disabled={isGeneratingAny || isGenerating} disabled={isGeneratingAny || isGenerating}
onChange={(e) => onChange={(e) => {
const raw = e.currentTarget.value.slice(0, 300)
onChangeRef(r.id, { onChangeRef(r.id, {
publisher: publisher: raw.trim() || undefined,
e.currentTarget.value.trim() || undefined,
}) })
} }}
/> />
</div> </div>
</div> </div>
@@ -1613,29 +1835,99 @@ function FormatoYCitasStep({
<div className="space-y-2 sm:col-span-3"> <div className="space-y-2 sm:col-span-3">
<Label className="text-xs">Año</Label> <Label className="text-xs">Año</Label>
<Input <Input
type="number"
inputMode="numeric"
step={1}
min={MIN_YEAR}
max={MAX_YEAR}
value={yearText} value={yearText}
disabled={isGeneratingAny || isGenerating} disabled={
placeholder="2024" isGeneratingAny ||
isGenerating ||
Boolean(r.isInPress)
}
placeholder={(MAX_YEAR - 1).toString()}
onChange={(e) => { onChange={(e) => {
const raw = e.currentTarget.value const next = sanitizeYearInput(e.currentTarget.value)
const year = Number.parseInt(raw.trim(), 10) setYearDraftById((prev) => ({
...prev,
[r.id]: next,
}))
const year = tryParseStrictYear(next)
onChangeRef(r.id, { onChangeRef(r.id, {
year: Number.isFinite(year) ? year : undefined, 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>
<div className="space-y-2 sm:col-span-9"> <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> <Label className="text-xs">ISBN</Label>
<Input <Input
value={isbnText} value={isbnText}
maxLength={20}
disabled={isGeneratingAny || isGenerating} disabled={isGeneratingAny || isGenerating}
onChange={(e) => onChange={(e) => {
const next = e.currentTarget.value.slice(0, 20)
onChangeRef(r.id, { onChangeRef(r.id, {
isbn: e.currentTarget.value.trim() || undefined, isbn: next.trim() || undefined,
}) })
} }}
/> />
</div> </div>
</div> </div>
@@ -1652,11 +1944,6 @@ function FormatoYCitasStep({
Cita generada Cita generada
</p> </p>
)} )}
{/* {infoText ? (
<p className="text-muted-foreground mt-1 text-xs">
{infoText}
</p>
) : null} */}
</div> </div>
{isGenerating ? ( {isGenerating ? (
<Loader2 className="text-muted-foreground mt-0.5 h-4 w-4 animate-spin" /> <Loader2 className="text-muted-foreground mt-0.5 h-4 w-4 animate-spin" />
@@ -1672,7 +1959,7 @@ function FormatoYCitasStep({
</div> </div>
</div> </div>
) )
} })
function ResumenStep({ function ResumenStep({
metodo, metodo,
@@ -1689,7 +1976,11 @@ function ResumenStep({
const basicas = refs.filter((r) => r.tipo === 'BASICA') const basicas = refs.filter((r) => r.tipo === 'BASICA')
const complementarias = refs.filter((r) => r.tipo === 'COMPLEMENTARIA') const complementarias = refs.filter((r) => r.tipo === 'COMPLEMENTARIA')
const metodoLabel = const metodoLabel =
metodo === 'MANUAL' ? 'Manual' : metodo === 'IA' ? 'Buscar en línea' : '—' metodo === 'MANUAL'
? 'Manual'
: metodo === 'EN_LINEA'
? 'Buscar en línea'
: '—'
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -1732,17 +2023,41 @@ function ResumenStep({
key={r.id} key={r.id}
className="bg-background rounded-md border p-3 text-sm shadow-sm" className="bg-background rounded-md border p-3 text-sm shadow-sm"
> >
<div className="mb-1 flex min-w-0 items-baseline gap-2"> {(() => {
<p className="min-w-0 truncate font-medium">{r.title}</p> const warnings = [
{r.subtitle ? ( r.authors.length === 0 ? 'Falta autor(es)' : null,
<p className="text-muted-foreground min-w-0 truncate text-xs"> !r.isInPress && !r.year ? 'Falta año' : null,
{r.subtitle} !r.publisher ? 'Falta editorial' : null,
</p> !r.isbn ? 'Falta ISBN' : null,
) : null} ].filter(Boolean) as Array<string>
</div>
<p className="text-muted-foreground"> return (
{citations[r.id] ?? 'Sin cita generada'} <>
</p> <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>
@@ -1761,17 +2076,41 @@ function ResumenStep({
key={r.id} key={r.id}
className="bg-background rounded-md border p-3 text-sm shadow-sm" className="bg-background rounded-md border p-3 text-sm shadow-sm"
> >
<div className="mb-1 flex min-w-0 items-baseline gap-2"> {(() => {
<p className="min-w-0 truncate font-medium">{r.title}</p> const warnings = [
{r.subtitle ? ( r.authors.length === 0 ? 'Falta autor(es)' : null,
<p className="text-muted-foreground min-w-0 truncate text-xs"> !r.isInPress && !r.year ? 'Falta año' : null,
{r.subtitle} !r.publisher ? 'Falta editorial' : null,
</p> !r.isbn ? 'Falta ISBN' : null,
) : null} ].filter(Boolean) as Array<string>
</div>
<p className="text-muted-foreground"> return (
{citations[r.id] ?? 'Sin cita generada'} <>
</p> <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>