Ya se puede agregar bibliografía de manera manual o a partir de sugerencias encontradas en línea #158

Merged
Guillermo.Arrieta merged 2 commits from issue/150-propuesta-de-biliografas into main 2026-03-07 02:13:57 +00:00
3 changed files with 374 additions and 163 deletions
Showing only changes of commit 98be1a0405 - Show all commits

View File

@@ -1,18 +1,24 @@
import * as React from "react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<'textarea'>
>(({ className, ...props }, ref) => {
return ( return (
<textarea <textarea
ref={ref}
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className className,
)} )}
{...props} {...props}
/> />
) )
} })
Textarea.displayName = 'Textarea'
export { Textarea } export { Textarea }

View File

@@ -19,7 +19,7 @@ import type {
AsignaturaSugerida, AsignaturaSugerida,
DataAsignaturaSugerida, DataAsignaturaSugerida,
} from '@/features/asignaturas/nueva/types' } from '@/features/asignaturas/nueva/types'
import type { Database, TablesInsert } from '@/types/supabase' import type { Database, Tables, TablesInsert } from '@/types/supabase'
const EDGE = { const EDGE = {
generate_subject_suggestions: 'generate-subject-suggestions', generate_subject_suggestions: 'generate-subject-suggestions',
@@ -85,8 +85,8 @@ export type GoogleBooksVolume = {
export async function buscar_bibliografia( export async function buscar_bibliografia(
input: BuscarBibliografiaRequest, input: BuscarBibliografiaRequest,
): Promise<Array<GoogleBooksVolume>> { ): Promise<Array<GoogleBooksVolume>> {
const q = input?.searchTerms?.q const q = input.searchTerms.q
const maxResults = input?.searchTerms?.maxResults const maxResults = input.searchTerms.maxResults
if (typeof q !== 'string' || q.trim().length < 1) { if (typeof q !== 'string' || q.trim().length < 1) {
throw new Error('q es requerido') throw new Error('q es requerido')
@@ -158,7 +158,7 @@ export type PlanEstudioInSubject = Pick<
export type EstructuraAsignaturaInSubject = Pick< export type EstructuraAsignaturaInSubject = Pick<
EstructuraAsignatura, EstructuraAsignatura,
'id' | 'nombre' | 'version' | 'definicion' 'id' | 'nombre' | 'definicion'
> >
/** /**
@@ -529,13 +529,9 @@ export async function lineas_delete(lineaId: string) {
return lineaId return lineaId
} }
export async function bibliografia_insert(entry: { export async function bibliografia_insert(
asignatura_id: string entry: TablesInsert<'bibliografia_asignatura'>,
tipo: 'BASICA' | 'COMPLEMENTARIA' ): Promise<Tables<'bibliografia_asignatura'>> {
cita: string
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
biblioteca_item_id?: string | null
}) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('bibliografia_asignatura') .from('bibliografia_asignatura')
@@ -544,7 +540,7 @@ export async function bibliografia_insert(entry: {
.single() .single()
if (error) throw error if (error) throw error
return data return data as Tables<'bibliografia_asignatura'>
} }
export async function bibliografia_update( export async function bibliografia_update(

View File

@@ -1,10 +1,11 @@
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import CSL from 'citeproc' import CSL from 'citeproc'
import { Loader2, Plus, RefreshCw, Sparkles } from 'lucide-react' import { Globe, Loader2, Plus, RefreshCw, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { BuscarBibliografiaRequest } from '@/data' import type { BuscarBibliografiaRequest } from '@/data'
import type { GoogleBooksVolume } from '@/data/api/subjects.api' import type { GoogleBooksVolume } from '@/data/api/subjects.api'
import type { TablesInsert } from '@/types/supabase'
import { defineStepper } from '@/components/stepper' import { defineStepper } from '@/components/stepper'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -26,6 +27,11 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { WizardLayout } from '@/components/wizard/WizardLayout' import { WizardLayout } from '@/components/wizard/WizardLayout'
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader' import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
import { buscar_bibliografia } from '@/data' import { buscar_bibliografia } from '@/data'
@@ -50,9 +56,15 @@ type CSLItem = {
ISBN?: string ISBN?: string
} }
type BibliografiaAsignaturaInsert = TablesInsert<'bibliografia_asignatura'>
type BibliografiaTipo = BibliografiaAsignaturaInsert['tipo']
type BibliografiaTipoFuente = NonNullable<
BibliografiaAsignaturaInsert['tipo_fuente']
>
type BibliografiaRef = { type BibliografiaRef = {
id: string id: string
source: 'IA' | 'MANUAL' source: BibliografiaTipoFuente
raw?: GoogleBooksVolume raw?: GoogleBooksVolume
title: string title: string
authors: Array<string> authors: Array<string>
@@ -60,14 +72,15 @@ type BibliografiaRef = {
year?: number year?: number
isbn?: string isbn?: string
tipo: 'BASICA' | 'COMPLEMENTARIA' tipo: BibliografiaTipo
} }
type WizardState = { type WizardState = {
metodo: MetodoBibliografia metodo: MetodoBibliografia
ia: { ia: {
q: string q: string
cantidadDeSugerencias: number cantidadDeSugerencias: number | null
showConservacionTooltip: boolean
sugerencias: Array<{ sugerencias: Array<{
id: string id: string
selected: boolean selected: boolean
@@ -95,7 +108,7 @@ type WizardState = {
} }
const Wizard = defineStepper( const Wizard = defineStepper(
{ id: 'metodo', title: 'Método', description: 'Manual o Con IA' }, { id: 'metodo', title: 'Método', description: 'Manual o Buscar en línea' },
{ {
id: 'paso2', id: 'paso2',
title: 'Datos básicos', title: 'Datos básicos',
@@ -138,7 +151,7 @@ function volumeToRef(volume: GoogleBooksVolume): BibliografiaRef {
return { return {
id: volume.id, id: volume.id,
source: 'IA', source: 'MANUAL',
raw: volume, raw: volume,
title, title,
authors, authors,
@@ -149,6 +162,50 @@ function volumeToRef(volume: GoogleBooksVolume): BibliografiaRef {
} }
} }
function AutoSizeTextarea({
value,
disabled,
placeholder,
className,
onChange,
}: {
value: string
disabled?: boolean
placeholder?: string
className?: string
onChange: (next: string) => void
}) {
const ref = useRef<HTMLTextAreaElement | null>(null)
const autosize = () => {
const el = ref.current
if (!el) return
el.style.height = '0px'
el.style.height = `${el.scrollHeight}px`
}
useLayoutEffect(() => {
autosize()
}, [value])
return (
<Textarea
ref={ref}
rows={1}
value={value}
disabled={disabled}
placeholder={placeholder}
className={cn('min-h-0 resize-none overflow-hidden pr-10', className)}
onChange={(e) => {
const el = e.currentTarget
el.style.height = '0px'
el.style.height = `${el.scrollHeight}px`
onChange(el.value)
}}
/>
)
}
function citeprocHtmlToPlainText(value: string) { function citeprocHtmlToPlainText(value: string) {
const input = value const input = value
if (!input) return '' if (!input) return ''
@@ -238,6 +295,7 @@ export function NuevaBibliografiaModalContainer({
ia: { ia: {
q: '', q: '',
cantidadDeSugerencias: 10, cantidadDeSugerencias: 10,
showConservacionTooltip: false,
sugerencias: [], sugerencias: [],
isLoading: false, isLoading: false,
errorMessage: null, errorMessage: null,
@@ -318,20 +376,47 @@ export function NuevaBibliografiaModalContainer({
const canContinueDesdePaso3 = Boolean(wizard.formato) && allCitationsReady const canContinueDesdePaso3 = Boolean(wizard.formato) && allCitationsReady
async function handleBuscarSugerencias() { async function handleBuscarSugerencias() {
const hadNoSugerenciasBefore = wizard.ia.sugerencias.length === 0
const cantidad = wizard.ia.cantidadDeSugerencias
if (
!Number.isFinite(cantidad ?? Number.NaN) ||
(cantidad as number) < 1 ||
(cantidad as number) > 40
) {
setWizard((w) => ({
...w,
ia: {
...w.ia,
errorMessage:
'La cantidad de sugerencias debe ser un entero entre 1 y 40 (o vacío).',
},
errorMessage: null,
}))
return
}
const selected = wizard.ia.sugerencias.filter((s) => s.selected)
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
ia: { ...w.ia, isLoading: true, errorMessage: null }, ia: {
...w.ia,
// Conservar únicamente las sugerencias seleccionadas.
sugerencias: w.ia.sugerencias.filter((s) => s.selected),
showConservacionTooltip: false,
isLoading: true,
errorMessage: null,
},
errorMessage: null, errorMessage: null,
})) }))
try { try {
const selectedCount = wizard.ia.sugerencias.filter( const selectedCount = selected.length
(s) => s.selected,
).length
const req: BuscarBibliografiaRequest = { const req: BuscarBibliografiaRequest = {
searchTerms: { searchTerms: {
q: wizard.ia.q, q: wizard.ia.q,
maxResults: wizard.ia.cantidadDeSugerencias + selectedCount, maxResults: (cantidad as number) + selectedCount,
// orderBy: ignorado por ahora // orderBy: ignorado por ahora
}, },
} }
@@ -343,7 +428,7 @@ export function NuevaBibliografiaModalContainer({
const newOnes = items const newOnes = items
.filter((it) => !existingById.has(it.id)) .filter((it) => !existingById.has(it.id))
.slice(0, w.ia.cantidadDeSugerencias) .slice(0, cantidad as number)
.map((it) => ({ id: it.id, selected: false, volume: it })) .map((it) => ({ id: it.id, selected: false, volume: it }))
return { return {
@@ -351,6 +436,8 @@ export function NuevaBibliografiaModalContainer({
ia: { ia: {
...w.ia, ...w.ia,
sugerencias: [...w.ia.sugerencias, ...newOnes], sugerencias: [...w.ia.sugerencias, ...newOnes],
showConservacionTooltip:
hadNoSugerenciasBefore && newOnes.length > 0,
isLoading: false, isLoading: false,
errorMessage: null, errorMessage: null,
}, },
@@ -374,7 +461,11 @@ export function NuevaBibliografiaModalContainer({
async function generateCitasForFormato( async function generateCitasForFormato(
formato: FormatoCita, formato: FormatoCita,
refs: Array<BibliografiaRef>, refs: Array<BibliografiaRef>,
options?: {
force?: boolean
},
) { ) {
const force = Boolean(options?.force)
setWizard((w) => { setWizard((w) => {
const nextIds = new Set(w.generatingIds) const nextIds = new Set(w.generatingIds)
refs.forEach((r) => nextIds.add(r.id)) refs.forEach((r) => nextIds.add(r.id))
@@ -432,9 +523,10 @@ export function NuevaBibliografiaModalContainer({
const merged: Record<string, string> = { ...existing } const merged: Record<string, string> = { ...existing }
for (const id of Object.keys(citations)) { for (const id of Object.keys(citations)) {
if (!merged[id] || merged[id].trim().length === 0) { merged[id] =
merged[id] = citations[id] ?? '' force || !merged[id] || merged[id].trim().length === 0
} ? (citations[id] ?? '')
: merged[id]
} }
nextEdits[formato] = merged nextEdits[formato] = merged
@@ -490,7 +582,7 @@ export function NuevaBibliografiaModalContainer({
asignatura_id: asignaturaId, asignatura_id: asignaturaId,
tipo: r.tipo, tipo: r.tipo,
cita: map[r.id] ?? '', cita: map[r.id] ?? '',
tipo_fuente: 'MANUAL', tipo_fuente: r.source,
biblioteca_item_id: null, biblioteca_item_id: null,
}), }),
), ),
@@ -532,7 +624,7 @@ export function NuevaBibliografiaModalContainer({
} }
footerSlot={ footerSlot={
<Wizard.Stepper.Controls> <Wizard.Stepper.Controls>
<div className="flex items-center justify-between"> <div className="flex grow items-center justify-between">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => methods.prev()} onClick={() => methods.prev()}
@@ -602,6 +694,15 @@ export function NuevaBibliografiaModalContainer({
isLoading={wizard.ia.isLoading} isLoading={wizard.ia.isLoading}
errorMessage={wizard.ia.errorMessage} errorMessage={wizard.ia.errorMessage}
sugerencias={wizard.ia.sugerencias} sugerencias={wizard.ia.sugerencias}
showConservacionTooltip={
wizard.ia.showConservacionTooltip
}
onDismissConservacionTooltip={() =>
setWizard((w) => ({
...w,
ia: { ...w.ia, showConservacionTooltip: false },
}))
}
onChange={(patch) => onChange={(patch) =>
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
@@ -664,7 +765,13 @@ export function NuevaBibliografiaModalContainer({
}} }}
onRegenerate={() => { onRegenerate={() => {
if (!wizard.formato) return if (!wizard.formato) return
void generateCitasForFormato(wizard.formato, wizard.refs) void generateCitasForFormato(
wizard.formato,
wizard.refs,
{
force: true,
},
)
}} }}
onChangeTipo={(id, tipo) => onChangeTipo={(id, tipo) =>
setWizard((w) => ({ setWizard((w) => ({
@@ -752,7 +859,7 @@ function MetodoStep({
> >
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Sparkles className="text-primary h-5 w-5" /> Con IA <Globe className="text-primary h-5 w-5" /> Buscar en línea
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Busca sugerencias y selecciona las mejores. Busca sugerencias y selecciona las mejores.
@@ -769,11 +876,13 @@ function SugerenciasStep({
isLoading, isLoading,
errorMessage, errorMessage,
sugerencias, sugerencias,
showConservacionTooltip,
onDismissConservacionTooltip,
onChange, onChange,
onGenerate, onGenerate,
}: { }: {
q: string q: string
cantidad: number cantidad: number | null
isLoading: boolean isLoading: boolean
errorMessage: string | null errorMessage: string | null
sugerencias: Array<{ sugerencias: Array<{
@@ -781,10 +890,12 @@ function SugerenciasStep({
selected: boolean selected: boolean
volume: GoogleBooksVolume volume: GoogleBooksVolume
}> }>
showConservacionTooltip: boolean
onDismissConservacionTooltip: () => void
onChange: ( onChange: (
patch: Partial<{ patch: Partial<{
q: string q: string
cantidadDeSugerencias: number cantidadDeSugerencias: number | null
sugerencias: any sugerencias: any
}>, }>,
) => void ) => void
@@ -792,6 +903,12 @@ function SugerenciasStep({
}) { }) {
const selectedCount = sugerencias.filter((s) => s.selected).length const selectedCount = sugerencias.filter((s) => s.selected).length
const cantidadIsValid =
typeof cantidad === 'number' &&
Number.isFinite(cantidad) &&
cantidad >= 1 &&
cantidad <= 40
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
@@ -818,13 +935,27 @@ function SugerenciasStep({
type="number" type="number"
min={1} min={1}
max={40} max={40}
value={cantidad} step={1}
onChange={(e) => inputMode="numeric"
onChange({ placeholder="Ej. 10"
cantidadDeSugerencias: value={cantidad ?? ''}
Number.parseInt(e.target.value || '0', 10) || 0, onKeyDown={(e) => {
}) if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
} e.preventDefault()
}
}}
onChange={(e) => {
const raw = e.target.value
if (raw === '') {
onChange({ cantidadDeSugerencias: null })
return
}
const asNumber = Number(raw)
if (!Number.isFinite(asNumber)) return
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(Math.max(n >= 1 ? n : 1, 1), 40)
onChange({ cantidadDeSugerencias: capped })
}}
/> />
</div> </div>
@@ -832,7 +963,7 @@ function SugerenciasStep({
type="button" type="button"
variant="outline" variant="outline"
onClick={onGenerate} onClick={onGenerate}
disabled={isLoading || q.trim().length === 0} disabled={isLoading || q.trim().length === 0 || !cantidadIsValid}
className="gap-2" className="gap-2"
> >
{isLoading ? ( {isLoading ? (
@@ -846,12 +977,6 @@ function SugerenciasStep({
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between gap-3">
<div className="text-muted-foreground text-sm">
{selectedCount} seleccionadas
</div>
</div>
{errorMessage ? ( {errorMessage ? (
<div className="text-destructive text-sm">{errorMessage}</div> <div className="text-destructive text-sm">{errorMessage}</div>
) : null} ) : null}
@@ -859,7 +984,34 @@ function SugerenciasStep({
</Card> </Card>
<div className="space-y-2"> <div className="space-y-2">
<div className="text-sm font-medium">Sugerencias</div> <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"> <div className="max-h-96 space-y-1 overflow-y-auto pr-1">
{sugerencias.map((s) => { {sugerencias.map((s) => {
const info = s.volume.volumeInfo ?? {} const info = s.volume.volumeInfo ?? {}
@@ -1062,28 +1214,25 @@ function FormatoYCitasStep({
generatingIds: Set<string> generatingIds: Set<string>
onChangeFormato: (formato: FormatoCita | null) => void onChangeFormato: (formato: FormatoCita | null) => void
onRegenerate: () => void onRegenerate: () => void
onChangeTipo: (id: string, tipo: 'BASICA' | 'COMPLEMENTARIA') => void onChangeTipo: (id: string, tipo: BibliografiaTipo) => void
onChangeCita: (id: string, value: string) => void onChangeCita: (id: string, value: string) => void
}) { }) {
const isGeneratingAny = generatingIds.size > 0 const isGeneratingAny = generatingIds.size > 0
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<Card> {/* 1. SECCIÓN DE CONTROLES: Sutil, compacta y sticky */}
<CardHeader> <div className="bg-muted/40 border-border sticky top-0 z-10 rounded-lg border p-4 backdrop-blur-md">
<CardTitle>Formato</CardTitle> <div className="flex flex-col items-end justify-between gap-4 sm:flex-row">
<CardDescription> <div className="w-full flex-1 space-y-1.5 sm:max-w-xs">
Selecciona un formato para generar las citas. <Label className="text-muted-foreground text-xs tracking-wider uppercase">
</CardDescription> Formato de citación
</CardHeader> </Label>
<CardContent className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-55 flex-1">
<Label>Formato</Label>
<Select <Select
value={formato ?? ''} value={formato ?? ''}
onValueChange={(v) => onChangeFormato(v as any)} onValueChange={(v) => onChangeFormato(v as any)}
> >
<SelectTrigger> <SelectTrigger className="bg-background">
<SelectValue placeholder="Seleccionar…" /> <SelectValue placeholder="Seleccionar…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1097,77 +1246,94 @@ function FormatoYCitasStep({
<Button <Button
type="button" type="button"
variant="outline" variant="secondary" // Cambiado a secondary para menor peso visual
className="gap-2" className="w-full gap-2 sm:w-auto"
onClick={onRegenerate} onClick={onRegenerate}
disabled={!formato || refs.length === 0 || isGeneratingAny} disabled={!formato || refs.length === 0 || isGeneratingAny}
> >
<RefreshCw className="h-4 w-4" /> Regenerar <RefreshCw className="h-4 w-4" /> Regenerar citas
</Button> </Button>
</CardContent> </div>
</Card> </div>
<div className="space-y-3"> {/* 2. SECCIÓN DE LISTA: Separación visual clara */}
{refs.map((r) => { <div className="space-y-4">
const infoText = [ {/* {refs.length > 0 && (
r.title, <div className="flex items-center gap-2">
r.authors.join(', '), <h3 className="text-muted-foreground text-sm font-medium">
r.publisher, Referencias añadidas
r.year ? String(r.year) : undefined, </h3>
] <Badge variant="secondary" className="text-xs">
.filter(Boolean) {refs.length}
.join(' • ') </Badge>
</div>
)} */}
const isGenerating = generatingIds.has(r.id) <div className="space-y-3">
return ( {refs.map((r) => {
<Card key={r.id}> const infoText = [
<CardHeader> r.authors.join(', '),
<CardTitle className="text-base">{r.title}</CardTitle> r.publisher,
<CardDescription className="wrap-break-word"> r.year ? String(r.year) : undefined,
{infoText} ]
</CardDescription> .filter(Boolean)
</CardHeader> .join(' • ')
<CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> const isGenerating = generatingIds.has(r.id)
<div className="space-y-2">
<Label>Cita</Label> return (
<Input <Card key={r.id} className="overflow-hidden">
value={citations[r.id] ?? ''} <CardHeader className="bg-muted/10">
onChange={(e) => onChangeCita(r.id, e.target.value)} <CardTitle className="text-base leading-tight">
disabled={isGenerating || isGeneratingAny} {r.title}
placeholder="Cita generada…" </CardTitle>
/> <CardDescription className="wrap-break-word">
{isGenerating ? ( {infoText}
<div className="text-muted-foreground flex items-center gap-2 text-xs"> </CardDescription>
<Loader2 className="h-3.5 w-3.5 animate-spin" />{' '} </CardHeader>
Generando cita <CardContent>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12">
<div className="space-y-2 sm:col-span-9">
<Label className="text-xs">Cita formateada</Label>
<div className="relative">
<AutoSizeTextarea
value={citations[r.id] ?? ''}
onChange={(next) => onChangeCita(r.id, next)}
disabled={isGenerating || isGeneratingAny}
placeholder="Cita generada…"
/>
{isGenerating && (
<div className="absolute inset-y-0 right-3 flex items-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
)}
</div> </div>
) : null} </div>
</div>
<div className="space-y-2"> <div className="flex w-full flex-col items-start gap-2 sm:col-span-3 sm:items-stretch">
<Label>Tipo</Label> <Label className="text-xs">Tipo</Label>
<Select <Select
value={r.tipo} value={r.tipo}
onValueChange={(v) => onChangeTipo(r.id, v as any)} onValueChange={(v) => onChangeTipo(r.id, v as any)}
disabled={isGenerating || isGeneratingAny} disabled={isGenerating || isGeneratingAny}
> >
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="BASICA">Básica</SelectItem> <SelectItem value="BASICA">Básica</SelectItem>
<SelectItem value="COMPLEMENTARIA"> <SelectItem value="COMPLEMENTARIA">
Complementaria Complementaria
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div>
</div> </div>
</div> </CardContent>
</CardContent> </Card>
</Card> )
) })}
})} </div>
</div> </div>
</div> </div>
) )
@@ -1184,41 +1350,84 @@ function ResumenStep({
refs: Array<BibliografiaRef> refs: Array<BibliografiaRef>
citations: Record<string, string> citations: Record<string, string>
}) { }) {
return ( // 1. Separar las referencias
<div className="space-y-4"> const basicas = refs.filter((r) => r.tipo === 'BASICA')
<Card> const complementarias = refs.filter((r) => r.tipo === 'COMPLEMENTARIA')
<CardHeader> const metodoLabel =
<CardTitle>Resumen</CardTitle> metodo === 'MANUAL' ? 'Manual' : metodo === 'IA' ? 'Buscar en línea' : '—'
<CardDescription>Revisa antes de agregar.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<span className="font-medium">Método:</span> {metodo ?? '—'}
</div>
<div>
<span className="font-medium">Formato:</span> {formato ?? '—'}
</div>
<div>
<span className="font-medium">Referencias:</span> {refs.length}
</div>
</CardContent>
</Card>
<div className="space-y-2"> return (
{refs.map((r) => ( <div className="space-y-8">
<Card key={r.id}> {/* Panel de Resumen General */}
<CardHeader> <div className="bg-muted/40 rounded-lg border p-4">
<CardTitle className="text-base">{r.title}</CardTitle> <h3 className="text-foreground mb-4 text-sm font-semibold">
<CardDescription> Resumen de importación
{r.tipo === 'BASICA' ? 'Básica' : 'Complementaria'} </h3>
</CardDescription> <div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
</CardHeader> <div>
<CardContent> <p className="text-muted-foreground text-xs uppercase">Método</p>
<div className="text-sm">{citations[r.id] ?? ''}</div> <p className="font-medium">{metodoLabel}</p>
</CardContent> </div>
</Card> <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> </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"
>
<p className="mb-1 font-medium">{r.title}</p>
<p className="text-muted-foreground">
{citations[r.id] ?? 'Sin cita generada'}
</p>
</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"
>
<p className="mb-1 font-medium">{r.title}</p>
<p className="text-muted-foreground">
{citations[r.id] ?? 'Sin cita generada'}
</p>
</div>
))}
</div>
</div>
)}
</div> </div>
) )
} }