import { useEffect, useMemo, useRef, useState } from "react"; import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon, SparkleIcon } from "../ui/Icons"; import { CustomSelect } from "../ui/CustomSelect"; import { Spinner } from "../ui/Spinner"; import { Badge } from "../ui/Badge"; const COLUMNS = [ { key: "title", label: "Título", thClass: "w-[35%]" }, { key: "journal", label: "Revista / Fuente", thClass: "w-[28%]", tdClass: "break-words" }, { key: "publication_year", label: "Año", thClass: "w-16" }, { key: "doi", label: "DOI" }, { key: "type", label: "Tipo", thClass: "w-20" }, ]; const PAGE_SIZE = 15; const EMPTY_SELECTION = new Set(); function SortIcon({ active, direction }) { const path = direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z"; return ( ); } function sortPublications(rows, key, direction) { const sorted = [...rows].sort((a, b) => { const va = a[key]; const vb = b[key]; const cmp = typeof va === "string" ? va.localeCompare(vb) : (va ?? 0) - (vb ?? 0); return direction === "asc" ? cmp : -cmp; }); return sorted; } /** * Tri-state checkbox. We can't express `indeterminate` via React props, so * we set it imperatively on the DOM node whenever the flag changes. */ function TriStateCheckbox({ checked, indeterminate = false, onChange, ariaLabel }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate && !checked; }, [indeterminate, checked]); return ( ); } /** * Publications table. UI-state (filter, sort, pagination) lives here; the * *selection* set is lifted to the parent so export / bulk actions can see * it. Data, loading and error states are also driven by the parent so * retries and toasts can be handled in one place. * * Selection semantics: * - The master checkbox toggles only the rows on the current page. * - Selection is stored by ID in the parent and persists across pages, * filters and sorts so the user can select page by page. */ export function PublicationsTable({ publications, loading = false, error = null, onRetry, selectedIds = EMPTY_SELECTION, onSelectedIdsChange, isAuthenticated = false, }) { const [filter, setFilter] = useState(""); const [sortKey, setSortKey] = useState("publication_year"); const [sortDir, setSortDir] = useState("desc"); const [page, setPage] = useState(1); const [filtersOpen, setFiltersOpen] = useState(false); const [yearFrom, setYearFrom] = useState(""); const [yearTo, setYearTo] = useState(""); const availableYears = useMemo(() => { const years = publications .map((p) => p.publication_year) .filter((y) => typeof y === "number" && Number.isFinite(y)); if (years.length === 0) return []; const min = Math.min(...years); const max = Math.max(...years, new Date().getFullYear()); const list = []; for (let y = max; y >= min; y -= 1) list.push(y); return list; }, [publications]); const hasYearFilter = yearFrom !== "" || yearTo !== ""; useEffect(() => { if (availableYears.length === 0) return; if (yearFrom && !availableYears.includes(Number(yearFrom))) setYearFrom(""); if (yearTo && !availableYears.includes(Number(yearTo))) setYearTo(""); }, [availableYears, yearFrom, yearTo]); const filtered = useMemo(() => { const needle = filter.trim().toLowerCase(); let rows = publications; if (needle) { rows = rows.filter( (p) => (p.title ?? "").toLowerCase().includes(needle) || (p.journal ?? "").toLowerCase().includes(needle) || String(p.publication_year ?? "").includes(needle) || (p.doi ?? "").toLowerCase().includes(needle), ); } if (hasYearFilter) { const from = yearFrom ? Number(yearFrom) : null; const to = yearTo ? Number(yearTo) : null; rows = rows.filter((p) => { const year = p.publication_year; if (typeof year !== "number" || !Number.isFinite(year)) return false; if (from !== null && year < from) return false; if (to !== null && year > to) return false; return true; }); } return sortPublications(rows, sortKey, sortDir); }, [publications, filter, yearFrom, yearTo, hasYearFilter, sortKey, sortDir]); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const currentPage = Math.min(page, totalPages); const pageRows = useMemo(() => { const start = (currentPage - 1) * PAGE_SIZE; return filtered.slice(start, start + PAGE_SIZE); }, [filtered, currentPage]); const pageSelectionStats = useMemo(() => { if (pageRows.length === 0) { return { allChecked: false, anyChecked: false }; } let count = 0; for (const pub of pageRows) { if (selectedIds.has(pub.id)) count += 1; } return { allChecked: count === pageRows.length, anyChecked: count > 0, }; }, [pageRows, selectedIds]); function toggleSort(key) { if (sortKey === key) { setSortDir((d) => (d === "asc" ? "desc" : "asc")); setPage(1); } else { setSortKey(key); setSortDir("desc"); setPage(1); } } function emit(nextSet) { onSelectedIdsChange?.(nextSet); } function toggleRow(id) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); emit(next); } function toggleCurrentPage() { const next = new Set(selectedIds); if (pageSelectionStats.allChecked) { for (const pub of pageRows) next.delete(pub.id); } else { for (const pub of pageRows) next.add(pub.id); } emit(next); } function handleYearFromChange(value) { setYearFrom(value); // Si el usuario elige un "Desde" mayor que el "Hasta" actual, // auto-corregimos el "Hasta" para preservar un rango coherente. if (value && yearTo && Number(value) > Number(yearTo)) { setYearTo(value); } setPage(1); } function handleYearToChange(value) { setYearTo(value); if (value && yearFrom && Number(value) < Number(yearFrom)) { setYearFrom(value); } setPage(1); } function clearYearFilter() { setYearFrom(""); setYearTo(""); setPage(1); } const pageStart = filtered.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1; const pageEnd = Math.min(currentPage * PAGE_SIZE, filtered.length); const yearFilterSummary = hasYearFilter ? yearFrom && yearTo && yearFrom === yearTo ? `Año: ${yearFrom}` : `Años: ${yearFrom || "…"} – ${yearTo || "…"}` : null; return (
{/* Toolbar */}

Publicaciones

{filtered.length} de {publications.length} resultados {yearFilterSummary && ( <> {" · "} {yearFilterSummary} )} {selectedIds.size > 0 && ( <> {" · "} {selectedIds.size} seleccionada {selectedIds.size === 1 ? "" : "s"} )}

{ setFilter(e.target.value); setPage(1); }} className="w-full rounded-lg border border-surface-border-strong bg-surface-secondary py-2 pl-9 pr-3.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent sm:w-[220px]" />
{filtersOpen && (
({ value: String(y), label: String(y), }))} />
({ value: String(y), label: String(y), }))} />
{hasYearFilter && ( )} {availableYears.length === 0 && (

Aún no hay años disponibles.

)}
)}
{/* Body */}
{error ? ( ) : loading ? ( ) : ( <>
{filtered.length === 0 ? (

No se encontraron publicaciones con los filtros aplicados.

) : ( <>
{pageRows.map((pub, i) => { const isSelected = selectedIds.has(pub.id); return (
toggleRow(pub.id)} ariaLabel={`Seleccionar publicación ${pub.title}`} />
{isAuthenticated && pub.downloaded_by_me === false && ( Nuevo )}

{pub.title}

Revista:{" "} {pub.journal || "—"}

Año:{" "} {pub.publication_year ?? "—"}

DOI:{" "} {pub.doi ? ( {pub.doi} ) : ( )}

); })}
)}
{COLUMNS.map((col) => ( ))} {filtered.length === 0 ? ( ) : ( pageRows.map((pub, i) => { const isSelected = selectedIds.has(pub.id); return ( ); }) )}
e.stopPropagation()} > toggleSort(col.key)} className={`select-none border-b border-surface-border/60 px-4 py-2.5 text-left text-xs font-medium tracking-wide text-ink-secondary${col.thClass ? ` ${col.thClass}` : ""}`} > {col.label.toUpperCase()}
No se encontraron publicaciones con los filtros aplicados.
{ e.stopPropagation(); toggleRow(pub.id); }} > toggleRow(pub.id)} ariaLabel={`Seleccionar publicación ${pub.title}`} /> {isAuthenticated && pub.downloaded_by_me === false && ( Nuevo )} {pub.title} {pub.journal || "—"} {pub.publication_year ?? "—"} {pub.doi ? ( e.stopPropagation()} className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline" > {pub.doi} ) : ( )}
)}
{/* Pagination */} {!loading && !error && filtered.length > 0 && ( setPage((p) => Math.max(1, Math.min(p, totalPages) - 1))} onNext={() => setPage((p) => Math.min(totalPages, Math.min(p, totalPages) + 1))} /> )}
); } function PaginationBar({ page, totalPages, pageStart, pageEnd, total, onPrev, onNext, }) { const hasPrev = page > 1; const hasNext = page < totalPages; const isSinglePage = totalPages <= 1; const noun = total === 1 ? "publicación" : "publicaciones"; const visibleCount = pageEnd - pageStart + 1; return (
{isSinglePage ? ( // Caso "todo entra en una página": evitamos el "del X al Y" // porque coincide con el total y resulta ruidoso. Un simple // "Mostrando N de N publicaciones" comunica lo mismo con menos // palabras.

Mostrando{" "} {visibleCount}{" "} de {total}{" "} {noun}

) : (

Mostrando del{" "} {pageStart}{" "} al {pageEnd}{" "} de un total de{" "} {total} {noun}

)} {!isSinglePage && (
Página {page}{" "} de {totalPages}
)}
); } function LoadingState() { return (

Cargando publicaciones…

); } function ErrorState({ error, onRetry }) { return (

No se pudieron cargar las publicaciones

{error?.message ?? "Error desconocido."}

{onRetry && ( )}
); }