{pub.title}
Revista:{" "} {pub.journal || "—"}
Año:{" "} {pub.publication_year ?? "—"}
DOI:{" "} {pub.doi ? ( {pub.doi} ) : ( — )}
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 (
{filtered.length} de {publications.length} resultados
{yearFilterSummary && (
<>
{" · "}
{yearFilterSummary}
>
)}
{selectedIds.size > 0 && (
<>
{" · "}
{selectedIds.size} seleccionada
{selectedIds.size === 1 ? "" : "s"}
>
)}
Filtrar por año de publicación
Aún no hay años disponibles.
No se encontraron publicaciones con los filtros aplicados.
{pub.title}
Revista:{" "}
{pub.journal || "—"}
Año:{" "}
{pub.publication_year ?? "—"}
DOI:{" "}
{pub.doi ? (
{pub.doi}
) : (
—
)}
Publicaciones
>
)}
{filtered.length === 0 ? (
e.stopPropagation()}
>
{COLUMNS.map((col) => (
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()}
))}
) : (
pageRows.map((pub, i) => {
const isSelected = selectedIds.has(pub.id);
return (
No se encontraron publicaciones con los filtros aplicados.
);
})
)}
{
e.stopPropagation();
toggleRow(pub.id);
}}
>
{isAuthenticated && pub.downloaded_by_me === false && (
{pub.journal || "—"}
{pub.publication_year ?? "—"}
{pub.doi ? (
e.stopPropagation()}
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
>
{pub.doi}
) : (
—
)}
Mostrando{" "} {visibleCount}{" "} de {total}{" "} {noun}
) : (Mostrando del{" "} {pageStart}{" "} al {pageEnd}{" "} de un total de{" "} {total} {noun}
)} {!isSinglePage && (Cargando publicaciones…
No se pudieron cargar las publicaciones
{error?.message ?? "Error desconocido."}