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.
This commit is contained in:
@@ -8,7 +8,14 @@ import {
|
||||
RefreshCw,
|
||||
X,
|
||||
} 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 {
|
||||
@@ -101,6 +108,9 @@ const IDIOMA_TO_OPEN_LIBRARY: Record<IdiomaBibliografia, string | undefined> = {
|
||||
RU: 'rus',
|
||||
}
|
||||
|
||||
const MIN_YEAR = 1450
|
||||
const MAX_YEAR = new Date().getFullYear() + 1
|
||||
|
||||
type CSLAuthor = {
|
||||
family: string
|
||||
given: string
|
||||
@@ -112,7 +122,8 @@ type CSLItem = {
|
||||
title: string
|
||||
author: Array<CSLAuthor>
|
||||
publisher?: string
|
||||
issued?: { 'date-parts': Array<Array<number>> }
|
||||
issued?: { 'date-parts': Array<Array<number>>; circa?: boolean }
|
||||
status?: string
|
||||
ISBN?: string
|
||||
}
|
||||
|
||||
@@ -131,6 +142,8 @@ type BibliografiaRef = {
|
||||
authors: Array<string>
|
||||
publisher?: string
|
||||
year?: number
|
||||
yearIsApproximate?: boolean
|
||||
isInPress?: boolean
|
||||
isbn?: string
|
||||
|
||||
tipo: BibliografiaTipo
|
||||
@@ -209,6 +222,19 @@ function tryParseYear(publishedDate?: string): number | 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 {
|
||||
try {
|
||||
const c = (globalThis as any).crypto
|
||||
@@ -425,6 +451,8 @@ export function NuevaBibliografiaModalContainer({
|
||||
const navigate = useNavigate()
|
||||
const createBibliografia = useCreateBibliografia()
|
||||
|
||||
const formatoStepRef = useRef<FormatoYCitasStepHandle | null>(null)
|
||||
|
||||
const [wizard, setWizard] = useState<WizardState>({
|
||||
metodo: null,
|
||||
ia: {
|
||||
@@ -513,6 +541,8 @@ export function NuevaBibliografiaModalContainer({
|
||||
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
|
||||
|
||||
@@ -633,13 +663,21 @@ export function NuevaBibliografiaModalContainer({
|
||||
|
||||
const cslItems: Record<string, CSLItem> = {}
|
||||
for (const r of refs) {
|
||||
const trimmedTitle = r.title.trim()
|
||||
cslItems[r.id] = {
|
||||
id: r.id,
|
||||
type: 'book',
|
||||
title: r.title || 'Sin título',
|
||||
title: trimmedTitle || 'Sin título',
|
||||
author: r.authors.map(parsearAutor),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -797,7 +835,14 @@ export function NuevaBibliografiaModalContainer({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => methods.next()}
|
||||
onClick={() => {
|
||||
if (idx === 2) {
|
||||
const ok =
|
||||
formatoStepRef.current?.validateBeforeNext() ?? true
|
||||
if (!ok) return
|
||||
}
|
||||
methods.next()
|
||||
}}
|
||||
disabled={
|
||||
wizard.ia.isLoading ||
|
||||
wizard.isSaving ||
|
||||
@@ -908,6 +953,7 @@ export function NuevaBibliografiaModalContainer({
|
||||
{idx === 2 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<FormatoYCitasStep
|
||||
ref={formatoStepRef}
|
||||
refs={wizard.refs}
|
||||
formato={wizard.formato}
|
||||
citations={citationsForFormato}
|
||||
@@ -1068,7 +1114,8 @@ function SugerenciasStep({
|
||||
<Label>Búsqueda</Label>
|
||||
<Input
|
||||
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..."
|
||||
/>
|
||||
</div>
|
||||
@@ -1124,7 +1171,9 @@ function SugerenciasStep({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading || q.trim().length < 3}
|
||||
disabled={
|
||||
isLoading || q.trim().length < 3 || selectedCount >= 20
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -1334,9 +1383,19 @@ function DatosBasicosManualStep({
|
||||
<Label>Título</Label>
|
||||
<Input
|
||||
value={draft.title}
|
||||
maxLength={500}
|
||||
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>
|
||||
|
||||
@@ -1344,8 +1403,12 @@ function DatosBasicosManualStep({
|
||||
<Label>Autores (uno por línea)</Label>
|
||||
<Textarea
|
||||
value={draft.authorsText}
|
||||
maxLength={2000}
|
||||
onChange={(e) =>
|
||||
onChangeDraft({ ...draft, authorsText: e.target.value })
|
||||
onChangeDraft({
|
||||
...draft,
|
||||
authorsText: e.target.value.slice(0, 2000),
|
||||
})
|
||||
}
|
||||
className="min-h-22.5"
|
||||
/>
|
||||
@@ -1357,8 +1420,12 @@ function DatosBasicosManualStep({
|
||||
<Input
|
||||
value={draft.publisher}
|
||||
onChange={(e) =>
|
||||
onChangeDraft({ ...draft, publisher: e.target.value })
|
||||
onChangeDraft({
|
||||
...draft,
|
||||
publisher: e.target.value.slice(0, 300),
|
||||
})
|
||||
}
|
||||
maxLength={300}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -1366,9 +1433,23 @@ function DatosBasicosManualStep({
|
||||
<Input
|
||||
value={draft.yearText}
|
||||
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>
|
||||
@@ -1378,8 +1459,12 @@ function DatosBasicosManualStep({
|
||||
<Input
|
||||
value={draft.isbn}
|
||||
onChange={(e) =>
|
||||
onChangeDraft({ ...draft, isbn: e.target.value })
|
||||
onChangeDraft({
|
||||
...draft,
|
||||
isbn: e.target.value.slice(0, 20),
|
||||
})
|
||||
}
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1387,17 +1472,20 @@ function DatosBasicosManualStep({
|
||||
type="button"
|
||||
disabled={!canAdd}
|
||||
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 = {
|
||||
id: `manual-${randomUUID()}`,
|
||||
source: 'MANUAL',
|
||||
title: draft.title.trim(),
|
||||
title,
|
||||
authors: draft.authorsText
|
||||
.split(/\r?\n/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean),
|
||||
publisher: draft.publisher.trim() || undefined,
|
||||
year: Number.isFinite(year) ? year : undefined,
|
||||
year,
|
||||
isbn: draft.isbn.trim() || undefined,
|
||||
tipo: 'BASICA',
|
||||
}
|
||||
@@ -1449,16 +1537,11 @@ function DatosBasicosManualStep({
|
||||
)
|
||||
}
|
||||
|
||||
function FormatoYCitasStep({
|
||||
refs,
|
||||
formato,
|
||||
citations,
|
||||
generatingIds,
|
||||
onChangeFormato,
|
||||
onRegenerate,
|
||||
onChangeRef,
|
||||
onChangeTipo,
|
||||
}: {
|
||||
type FormatoYCitasStepHandle = {
|
||||
validateBeforeNext: () => boolean
|
||||
}
|
||||
|
||||
type FormatoYCitasStepProps = {
|
||||
refs: Array<BibliografiaRef>
|
||||
formato: FormatoCita | null
|
||||
citations: Record<string, string>
|
||||
@@ -1467,18 +1550,47 @@ function FormatoYCitasStep({
|
||||
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
|
||||
|
||||
// Remove drafts for refs that no longer exist
|
||||
for (const id of Object.keys(prev)) {
|
||||
if (!ids.has(id)) {
|
||||
if (next === prev) next = { ...prev }
|
||||
@@ -1486,7 +1598,6 @@ function FormatoYCitasStep({
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize drafts for new refs (do not override existing edits)
|
||||
for (const r of refs) {
|
||||
if (typeof next[r.id] !== 'string') {
|
||||
if (next === prev) next = { ...prev }
|
||||
@@ -1496,11 +1607,60 @@ function FormatoYCitasStep({
|
||||
|
||||
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">
|
||||
{/* 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="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">
|
||||
@@ -1525,7 +1685,7 @@ function FormatoYCitasStep({
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary" // Cambiado a secondary para menor peso visual
|
||||
variant="secondary"
|
||||
className="w-full gap-2 sm:w-auto"
|
||||
onClick={onRegenerate}
|
||||
disabled={!formato || refs.length === 0 || isGeneratingAny}
|
||||
@@ -1535,25 +1695,17 @@ function FormatoYCitasStep({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. SECCIÓN DE LISTA: Separación visual clara */}
|
||||
<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">
|
||||
{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 = 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 publisherText = r.publisher ?? ''
|
||||
|
||||
@@ -1562,15 +1714,59 @@ function FormatoYCitasStep({
|
||||
<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) =>
|
||||
onChangeRef(r.id, {
|
||||
title: e.currentTarget.value,
|
||||
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>
|
||||
|
||||
@@ -1599,10 +1795,11 @@ function FormatoYCitasStep({
|
||||
<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
|
||||
const nextText = e.currentTarget.value.slice(0, 2000)
|
||||
setAuthorsDraftById((prev) => ({
|
||||
...prev,
|
||||
[r.id]: nextText,
|
||||
@@ -1622,13 +1819,14 @@ function FormatoYCitasStep({
|
||||
<Label className="text-xs">Editorial</Label>
|
||||
<Input
|
||||
value={publisherText}
|
||||
maxLength={300}
|
||||
disabled={isGeneratingAny || isGenerating}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value.slice(0, 300)
|
||||
onChangeRef(r.id, {
|
||||
publisher:
|
||||
e.currentTarget.value.trim() || undefined,
|
||||
publisher: raw.trim() || undefined,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1637,29 +1835,99 @@ function FormatoYCitasStep({
|
||||
<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}
|
||||
placeholder="2024"
|
||||
disabled={
|
||||
isGeneratingAny ||
|
||||
isGenerating ||
|
||||
Boolean(r.isInPress)
|
||||
}
|
||||
placeholder={(MAX_YEAR - 1).toString()}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value
|
||||
const year = Number.parseInt(raw.trim(), 10)
|
||||
const next = sanitizeYearInput(e.currentTarget.value)
|
||||
setYearDraftById((prev) => ({
|
||||
...prev,
|
||||
[r.id]: next,
|
||||
}))
|
||||
|
||||
const year = tryParseStrictYear(next)
|
||||
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 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>
|
||||
<Input
|
||||
value={isbnText}
|
||||
maxLength={20}
|
||||
disabled={isGeneratingAny || isGenerating}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value.slice(0, 20)
|
||||
onChangeRef(r.id, {
|
||||
isbn: e.currentTarget.value.trim() || undefined,
|
||||
isbn: next.trim() || undefined,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1676,11 +1944,6 @@ function FormatoYCitasStep({
|
||||
Cita generada…
|
||||
</p>
|
||||
)}
|
||||
{/* {infoText ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{infoText}
|
||||
</p>
|
||||
) : null} */}
|
||||
</div>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="text-muted-foreground mt-0.5 h-4 w-4 animate-spin" />
|
||||
@@ -1696,7 +1959,7 @@ function FormatoYCitasStep({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ResumenStep({
|
||||
metodo,
|
||||
@@ -1760,8 +2023,20 @@ function ResumenStep({
|
||||
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>
|
||||
<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}
|
||||
@@ -1771,6 +2046,18 @@ function ResumenStep({
|
||||
<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>
|
||||
@@ -1789,8 +2076,20 @@ function ResumenStep({
|
||||
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>
|
||||
<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}
|
||||
@@ -1800,6 +2099,18 @@ function ResumenStep({
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user