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
@@ -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 {
|
||||||
@@ -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: {
|
||||||
@@ -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 ||
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -1124,7 +1171,9 @@ function SugerenciasStep({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onGenerate}
|
onClick={onGenerate}
|
||||||
disabled={isLoading || q.trim().length < 3}
|
disabled={
|
||||||
|
isLoading || q.trim().length < 3 || selectedCount >= 20
|
||||||
|
}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -1334,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>
|
||||||
|
|
||||||
@@ -1344,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"
|
||||||
/>
|
/>
|
||||||
@@ -1357,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">
|
||||||
@@ -1366,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>
|
||||||
@@ -1378,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>
|
||||||
|
|
||||||
@@ -1387,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',
|
||||||
}
|
}
|
||||||
@@ -1449,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>
|
||||||
@@ -1467,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 }
|
||||||
@@ -1486,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 }
|
||||||
@@ -1496,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">
|
||||||
@@ -1525,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}
|
||||||
@@ -1535,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 ?? ''
|
||||||
|
|
||||||
@@ -1562,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>
|
||||||
|
|
||||||
@@ -1599,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,
|
||||||
@@ -1622,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>
|
||||||
@@ -1637,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>
|
||||||
@@ -1676,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" />
|
||||||
@@ -1696,7 +1959,7 @@ function FormatoYCitasStep({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
function ResumenStep({
|
function ResumenStep({
|
||||||
metodo,
|
metodo,
|
||||||
@@ -1760,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>
|
||||||
@@ -1789,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user