feat: enhance ExportDropdown and PublicationsTable components for improved export functionality
- Update ExportDropdown to support selected item count and use new icons for formats - Refactor PublicationsTable to include tri-state checkbox for selection management and year filtering - Modify DashboardPage to handle selected publication IDs for export - Adjust API service to support selective export based on publication IDs
This commit is contained in:
@@ -1,20 +1,22 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
DocumentIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
PackageIcon,
|
||||||
} from "../ui/Icons";
|
} from "../ui/Icons";
|
||||||
import { Spinner } from "../ui/Spinner";
|
import { Spinner } from "../ui/Spinner";
|
||||||
|
|
||||||
const FORMATS = [
|
const FORMATS = [
|
||||||
{
|
{
|
||||||
format: "xml",
|
format: "xml",
|
||||||
icon: "📄",
|
icon: <DocumentIcon size={20} className="shrink-0 text-ink-secondary" />,
|
||||||
label: "SWORD XML",
|
label: "SWORD XML",
|
||||||
desc: "Metadatos en formato Atom",
|
desc: "Metadatos en formato Atom",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
format: "zip",
|
format: "zip",
|
||||||
icon: "📦",
|
icon: <PackageIcon size={20} className="shrink-0 text-ink-secondary" />,
|
||||||
label: "Paquete ZIP",
|
label: "Paquete ZIP",
|
||||||
desc: "XML + ficheros adjuntos",
|
desc: "XML + ficheros adjuntos",
|
||||||
},
|
},
|
||||||
@@ -28,7 +30,11 @@ const FORMATS = [
|
|||||||
* `exportingFormat` (optional) lets the parent keep the button in a loading
|
* `exportingFormat` (optional) lets the parent keep the button in a loading
|
||||||
* state between clicks (e.g. while waiting for the backend blob).
|
* state between clicks (e.g. while waiting for the backend blob).
|
||||||
*/
|
*/
|
||||||
export function ExportDropdown({ onExport, exportingFormat = null }) {
|
export function ExportDropdown({
|
||||||
|
onExport,
|
||||||
|
exportingFormat = null,
|
||||||
|
selectedCount = 0,
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const rootRef = useRef(null);
|
const rootRef = useRef(null);
|
||||||
|
|
||||||
@@ -43,12 +49,17 @@ export function ExportDropdown({ onExport, exportingFormat = null }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isBusy = Boolean(exportingFormat);
|
const isBusy = Boolean(exportingFormat);
|
||||||
|
const hasSelection = selectedCount > 0;
|
||||||
|
|
||||||
function handlePick(format) {
|
function handlePick(format) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onExport(format);
|
onExport(format);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const idleLabel = hasSelection
|
||||||
|
? `Exportar seleccionadas (${selectedCount})`
|
||||||
|
: "Exportar todas";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={rootRef}>
|
<div className="relative" ref={rootRef}>
|
||||||
<button
|
<button
|
||||||
@@ -60,7 +71,7 @@ export function ExportDropdown({ onExport, exportingFormat = null }) {
|
|||||||
{isBusy ? <Spinner size={15} /> : <DownloadIcon />}
|
{isBusy ? <Spinner size={15} /> : <DownloadIcon />}
|
||||||
{isBusy
|
{isBusy
|
||||||
? `Exportando ${exportingFormat.toUpperCase()}...`
|
? `Exportando ${exportingFormat.toUpperCase()}...`
|
||||||
: "Exportar SWORD"}
|
: idleLabel}
|
||||||
{!isBusy && <ChevronDownIcon />}
|
{!isBusy && <ChevronDownIcon />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -77,9 +88,7 @@ export function ExportDropdown({ onExport, exportingFormat = null }) {
|
|||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-xl" aria-hidden>
|
{icon}
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-ink-primary">
|
<div className="text-sm font-medium text-ink-primary">
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AlertIcon, SearchIcon } from "../ui/Icons";
|
import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon } from "../ui/Icons";
|
||||||
import { Spinner } from "../ui/Spinner";
|
import { Spinner } from "../ui/Spinner";
|
||||||
import { Badge } from "../ui/Badge";
|
import { Badge } from "../ui/Badge";
|
||||||
|
|
||||||
@@ -11,6 +11,9 @@ const COLUMNS = [
|
|||||||
{ key: "type", label: "Tipo" },
|
{ key: "type", label: "Tipo" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
const EMPTY_SELECTION = new Set();
|
||||||
|
|
||||||
function SortIcon({ active, direction }) {
|
function SortIcon({ active, direction }) {
|
||||||
const path =
|
const path =
|
||||||
direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z";
|
direction === "asc" || !active ? "M6 8L3 5h6z" : "M6 4l3 3H3z";
|
||||||
@@ -40,67 +43,329 @@ function sortPublications(rows, key, direction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publications table. Owns only UI-state (filter + sort). Data, loading and
|
* Tri-state checkbox. We can't express `indeterminate` via React props, so
|
||||||
* error states are driven by the parent page so retries and toasts can be
|
* we set it imperatively on the DOM node whenever the flag changes.
|
||||||
* handled in one place.
|
*/
|
||||||
|
function TriStateCheckbox({ checked, indeterminate = false, onChange, ariaLabel }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate && !checked;
|
||||||
|
}, [indeterminate, checked]);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="h-4 w-4 cursor-pointer accent-brand-accent"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 the WHOLE currently-filtered set (not
|
||||||
|
* just the visible page). This matches the user mental model of
|
||||||
|
* "filtrar por 2024 → marcar todas de 2024".
|
||||||
|
* - Selection survives filter changes: the stored IDs remain even if
|
||||||
|
* those rows are no longer visible.
|
||||||
*/
|
*/
|
||||||
export function PublicationsTable({
|
export function PublicationsTable({
|
||||||
publications,
|
publications,
|
||||||
loading = false,
|
loading = false,
|
||||||
error = null,
|
error = null,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
selectedIds = EMPTY_SELECTION,
|
||||||
|
onSelectedIdsChange,
|
||||||
}) {
|
}) {
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [sortKey, setSortKey] = useState("publication_year");
|
const [sortKey, setSortKey] = useState("publication_year");
|
||||||
const [sortDir, setSortDir] = useState("desc");
|
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 filtered = useMemo(() => {
|
||||||
const needle = filter.trim().toLowerCase();
|
const needle = filter.trim().toLowerCase();
|
||||||
const rows = needle
|
let rows = publications;
|
||||||
? publications.filter(
|
|
||||||
(p) =>
|
if (needle) {
|
||||||
(p.title ?? "").toLowerCase().includes(needle) ||
|
rows = rows.filter(
|
||||||
(p.journal ?? "").toLowerCase().includes(needle) ||
|
(p) =>
|
||||||
String(p.publication_year ?? "").includes(needle) ||
|
(p.title ?? "").toLowerCase().includes(needle) ||
|
||||||
(p.doi ?? "").toLowerCase().includes(needle),
|
(p.journal ?? "").toLowerCase().includes(needle) ||
|
||||||
)
|
String(p.publication_year ?? "").includes(needle) ||
|
||||||
: publications;
|
(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);
|
return sortPublications(rows, sortKey, sortDir);
|
||||||
}, [publications, filter, 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 selectionStats = useMemo(() => {
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return { allChecked: false, anyChecked: false, selectedInFiltered: 0 };
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
for (const pub of filtered) {
|
||||||
|
if (selectedIds.has(pub.id)) count += 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allChecked: count === filtered.length,
|
||||||
|
anyChecked: count > 0,
|
||||||
|
selectedInFiltered: count,
|
||||||
|
};
|
||||||
|
}, [filtered, selectedIds]);
|
||||||
|
|
||||||
function toggleSort(key) {
|
function toggleSort(key) {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||||
|
setPage(1);
|
||||||
} else {
|
} else {
|
||||||
setSortKey(key);
|
setSortKey(key);
|
||||||
setSortDir("desc");
|
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 toggleAllFiltered() {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (selectionStats.allChecked) {
|
||||||
|
for (const pub of filtered) next.delete(pub.id);
|
||||||
|
} else {
|
||||||
|
for (const pub of filtered) 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 (
|
return (
|
||||||
<section className="overflow-hidden rounded-2xl border border-surface-border/60 bg-surface-primary">
|
<section className="overflow-hidden rounded-2xl border border-surface-border/60 bg-surface-primary">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-surface-border/60 px-5 py-4">
|
<div className="border-b border-surface-border/60">
|
||||||
<div>
|
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
|
||||||
<h3 className="text-base font-medium text-ink-primary">
|
<div>
|
||||||
Publicaciones
|
<h3 className="text-base font-medium text-ink-primary">
|
||||||
</h3>
|
Publicaciones
|
||||||
<p className="mt-0.5 text-xs text-ink-tertiary">
|
</h3>
|
||||||
{filtered.length} de {publications.length} resultados
|
<p className="mt-0.5 text-xs text-ink-tertiary">
|
||||||
</p>
|
{filtered.length} de {publications.length} resultados
|
||||||
</div>
|
{yearFilterSummary && (
|
||||||
<div className="relative">
|
<>
|
||||||
<input
|
{" · "}
|
||||||
type="text"
|
<span className="text-ink-secondary">{yearFilterSummary}</span>
|
||||||
placeholder="Filtrar publicaciones..."
|
</>
|
||||||
value={filter}
|
)}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
{selectedIds.size > 0 && (
|
||||||
className="w-[220px] 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"
|
<>
|
||||||
/>
|
{" · "}
|
||||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-tertiary/70">
|
<span className="font-medium text-brand-accent">
|
||||||
<SearchIcon />
|
{selectedIds.size} seleccionada
|
||||||
</span>
|
{selectedIds.size === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiltersOpen((o) => !o)}
|
||||||
|
aria-expanded={filtersOpen}
|
||||||
|
aria-controls="pubs-advanced-filters"
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-[13px] font-medium transition-colors ${
|
||||||
|
filtersOpen || hasYearFilter
|
||||||
|
? "border-brand-accent/50 bg-brand-accent/10 text-brand-accent"
|
||||||
|
: "border-surface-border-strong bg-surface-secondary text-ink-secondary hover:bg-surface-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FilterIcon />
|
||||||
|
Filtros
|
||||||
|
{hasYearFilter && (
|
||||||
|
<span
|
||||||
|
className="ml-0.5 inline-block h-1.5 w-1.5 rounded-full bg-brand-accent"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={`transition-transform ${filtersOpen ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filtrar publicaciones..."
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="w-[220px] 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"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-tertiary/70">
|
||||||
|
<SearchIcon />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filtersOpen && (
|
||||||
|
<div
|
||||||
|
id="pubs-advanced-filters"
|
||||||
|
className="flex flex-wrap items-end gap-4 border-t border-surface-border/60 bg-surface-secondary/40 px-5 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="year-from"
|
||||||
|
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
|
||||||
|
>
|
||||||
|
Desde año
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="year-from"
|
||||||
|
value={yearFrom}
|
||||||
|
onChange={(e) => handleYearFromChange(e.target.value)}
|
||||||
|
disabled={availableYears.length === 0}
|
||||||
|
className="rounded-md border border-surface-border-strong bg-surface-primary px-2.5 py-1.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Cualquiera</option>
|
||||||
|
{availableYears.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="year-to"
|
||||||
|
className="text-[11px] font-medium uppercase tracking-wide text-ink-tertiary"
|
||||||
|
>
|
||||||
|
Hasta año
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="year-to"
|
||||||
|
value={yearTo}
|
||||||
|
onChange={(e) => handleYearToChange(e.target.value)}
|
||||||
|
disabled={availableYears.length === 0}
|
||||||
|
className="rounded-md border border-surface-border-strong bg-surface-primary px-2.5 py-1.5 text-[13px] text-ink-primary outline-none focus:border-brand-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Cualquiera</option>
|
||||||
|
{availableYears.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{hasYearFilter && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearYearFilter}
|
||||||
|
className="mb-[2px] rounded-md px-2.5 py-1.5 text-xs font-medium text-ink-tertiary transition-colors hover:bg-surface-primary hover:text-ink-primary"
|
||||||
|
>
|
||||||
|
Limpiar rango
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{availableYears.length === 0 && (
|
||||||
|
<p className="mb-[2px] text-xs text-ink-tertiary">
|
||||||
|
Aún no hay años disponibles.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
@@ -110,9 +375,21 @@ export function PublicationsTable({
|
|||||||
) : loading ? (
|
) : loading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full min-w-[640px] border-collapse">
|
<table className="w-full min-w-[720px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-surface-secondary">
|
<tr className="bg-surface-secondary">
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="w-10 border-b border-surface-border/60 px-4 py-2.5 text-left"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<TriStateCheckbox
|
||||||
|
checked={selectionStats.allChecked}
|
||||||
|
indeterminate={selectionStats.anyChecked}
|
||||||
|
onChange={toggleAllFiltered}
|
||||||
|
ariaLabel="Seleccionar todas las publicaciones del filtro actual"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
{COLUMNS.map((col) => (
|
{COLUMNS.map((col) => (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
@@ -134,61 +411,160 @@ export function PublicationsTable({
|
|||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={COLUMNS.length}
|
colSpan={COLUMNS.length + 1}
|
||||||
className="p-10 text-center text-sm text-ink-tertiary"
|
className="p-10 text-center text-sm text-ink-tertiary"
|
||||||
>
|
>
|
||||||
No se encontraron publicaciones con ese filtro.
|
No se encontraron publicaciones con los filtros aplicados.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((pub, i) => (
|
pageRows.map((pub, i) => {
|
||||||
<tr
|
const isSelected = selectedIds.has(pub.id);
|
||||||
key={pub.id}
|
return (
|
||||||
className={`transition-colors hover:bg-surface-secondary/70 ${
|
<tr
|
||||||
i < filtered.length - 1
|
key={pub.id}
|
||||||
? "border-b border-surface-border/60"
|
className={`transition-colors ${
|
||||||
: ""
|
isSelected
|
||||||
}`}
|
? "bg-tag-article-bg/70 hover:bg-tag-article-bg"
|
||||||
>
|
: "hover:bg-surface-secondary/70"
|
||||||
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary">
|
} ${
|
||||||
{pub.title}
|
i < pageRows.length - 1
|
||||||
</td>
|
? "border-b border-surface-border/60"
|
||||||
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
|
: ""
|
||||||
{pub.journal || "—"}
|
}`}
|
||||||
</td>
|
>
|
||||||
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
|
<td
|
||||||
{pub.publication_year ?? "—"}
|
className="w-10 cursor-pointer px-4 py-3.5"
|
||||||
</td>
|
onClick={(e) => {
|
||||||
<td className="px-4 py-3.5">
|
e.stopPropagation();
|
||||||
{pub.doi ? (
|
toggleRow(pub.id);
|
||||||
<a
|
}}
|
||||||
href={`https://doi.org/${pub.doi}`}
|
>
|
||||||
target="_blank"
|
<TriStateCheckbox
|
||||||
rel="noopener noreferrer"
|
checked={isSelected}
|
||||||
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
|
onChange={() => toggleRow(pub.id)}
|
||||||
>
|
ariaLabel={`Seleccionar publicación ${pub.title}`}
|
||||||
{pub.doi}
|
/>
|
||||||
</a>
|
</td>
|
||||||
) : (
|
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary">
|
||||||
<span className="whitespace-nowrap font-mono text-xs text-ink-tertiary">
|
{pub.title}
|
||||||
—
|
</td>
|
||||||
</span>
|
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
|
||||||
)}
|
{pub.journal || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
|
||||||
<Badge type={pub.type} />
|
{pub.publication_year ?? "—"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td className="px-4 py-3.5">
|
||||||
))
|
{pub.doi ? (
|
||||||
|
<a
|
||||||
|
href={`https://doi.org/${pub.doi}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="whitespace-nowrap font-mono text-xs text-brand-accent hover:underline"
|
||||||
|
>
|
||||||
|
{pub.doi}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="whitespace-nowrap font-mono text-xs text-ink-tertiary">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<Badge type={pub.type} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!loading && !error && filtered.length > 0 && (
|
||||||
|
<PaginationBar
|
||||||
|
page={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageStart={pageStart}
|
||||||
|
pageEnd={pageEnd}
|
||||||
|
total={filtered.length}
|
||||||
|
onPrev={() => setPage((p) => Math.max(1, Math.min(p, totalPages) - 1))}
|
||||||
|
onNext={() => setPage((p) => Math.min(totalPages, Math.min(p, totalPages) + 1))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-surface-border/60 px-5 py-3">
|
||||||
|
{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.
|
||||||
|
<p className="text-xs text-ink-tertiary">
|
||||||
|
Mostrando{" "}
|
||||||
|
<span className="font-medium text-ink-secondary">{visibleCount}</span>{" "}
|
||||||
|
de <span className="font-medium text-ink-secondary">{total}</span>{" "}
|
||||||
|
{noun}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ink-tertiary">
|
||||||
|
Mostrando del{" "}
|
||||||
|
<span className="font-medium text-ink-secondary">{pageStart}</span>{" "}
|
||||||
|
al <span className="font-medium text-ink-secondary">{pageEnd}</span>{" "}
|
||||||
|
de un total de{" "}
|
||||||
|
<span className="font-medium text-ink-secondary">{total}</span> {noun}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isSinglePage && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={!hasPrev}
|
||||||
|
className="inline-flex items-center rounded-md border border-surface-border-strong bg-surface-primary px-3 py-1.5 text-xs font-medium text-ink-secondary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-ink-tertiary">
|
||||||
|
Página <span className="font-medium text-ink-primary">{page}</span>{" "}
|
||||||
|
de <span className="font-medium text-ink-primary">{totalPages}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!hasNext}
|
||||||
|
className="inline-flex items-center rounded-md border border-surface-border-strong bg-surface-primary px-3 py-1.5 text-xs font-medium text-ink-secondary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LoadingState() {
|
function LoadingState() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-ink-tertiary">
|
<div className="flex flex-col items-center justify-center gap-3 py-16 text-ink-tertiary">
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ export function SearchIcon({ size = 14, className = "" }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FilterIcon({ size = 14, className = "" }) {
|
||||||
|
return (
|
||||||
|
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
|
||||||
|
<path d="M3 5h18M6 12h12M10 19h4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AlertIcon({ size = 16, className = "" }) {
|
export function AlertIcon({ size = 16, className = "" }) {
|
||||||
return (
|
return (
|
||||||
<svg {...base} width={size} height={size} className={className}>
|
<svg {...base} width={size} height={size} className={className}>
|
||||||
@@ -102,3 +110,13 @@ export function AlertIcon({ size = 16, className = "" }) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PackageIcon({ size = 18, className = "" }) {
|
||||||
|
return (
|
||||||
|
<svg {...base} width={size} height={size} className={className}>
|
||||||
|
<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z" />
|
||||||
|
<path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12" />
|
||||||
|
<path d="M7.5 4.21l9 5.16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export function DashboardPage() {
|
|||||||
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
|
const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success
|
||||||
const [exportingFormat, setExportingFormat] = useState(null);
|
const [exportingFormat, setExportingFormat] = useState(null);
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||||
|
|
||||||
const loadResearcher = useCallback(
|
const loadResearcher = useCallback(
|
||||||
async (signal) => {
|
async (signal) => {
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +59,16 @@ export function DashboardPage() {
|
|||||||
setPubsError(null);
|
setPubsError(null);
|
||||||
try {
|
try {
|
||||||
const data = await getPublications(orcid, { signal });
|
const data = await getPublications(orcid, { signal });
|
||||||
if (!signal?.aborted) setPublications(data);
|
if (!signal?.aborted) {
|
||||||
|
setPublications(data);
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
if (prev.size === 0) return prev;
|
||||||
|
const alive = new Set(data.map((p) => p.id));
|
||||||
|
const next = new Set();
|
||||||
|
for (const id of prev) if (alive.has(id)) next.add(id);
|
||||||
|
return next.size === prev.size ? prev : next;
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (signal?.aborted) return;
|
if (signal?.aborted) return;
|
||||||
setPubsError(err);
|
setPubsError(err);
|
||||||
@@ -89,8 +100,6 @@ export function DashboardPage() {
|
|||||||
throw new Error(summary.message || "El backend rechazó la sincronización.");
|
throw new Error(summary.message || "El backend rechazó la sincronización.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// El backend devuelve un resumen del SyncJob, no el researcher.
|
|
||||||
// Refrescamos ambos recursos en paralelo.
|
|
||||||
await Promise.all([loadResearcher(), loadPublications()]);
|
await Promise.all([loadResearcher(), loadPublications()]);
|
||||||
|
|
||||||
setSyncStatus("success");
|
setSyncStatus("success");
|
||||||
@@ -115,7 +124,10 @@ export function DashboardPage() {
|
|||||||
async function handleExport(format) {
|
async function handleExport(format) {
|
||||||
setExportingFormat(format);
|
setExportingFormat(format);
|
||||||
try {
|
try {
|
||||||
const { blob, url } = await downloadExport(orcid, format);
|
const ids = Array.from(selectedIds);
|
||||||
|
const { blob, url } = await downloadExport(orcid, format, {
|
||||||
|
publicationIds: ids.length > 0 ? ids : undefined,
|
||||||
|
});
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
@@ -152,6 +164,7 @@ export function DashboardPage() {
|
|||||||
<ExportDropdown
|
<ExportDropdown
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
exportingFormat={exportingFormat}
|
exportingFormat={exportingFormat}
|
||||||
|
selectedCount={selectedIds.size}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -167,6 +180,8 @@ export function DashboardPage() {
|
|||||||
loading={pubsLoading}
|
loading={pubsLoading}
|
||||||
error={pubsError}
|
error={pubsError}
|
||||||
onRetry={() => loadPublications()}
|
onRetry={() => loadPublications()}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onSelectedIdsChange={setSelectedIds}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
|
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ import {
|
|||||||
|
|
||||||
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||||
|
|
||||||
/**
|
|
||||||
* Si el backend no está disponible, poner `VITE_USE_MOCKS=true` en
|
|
||||||
* `.env.local` para servir todas las llamadas desde `mocks.js`.
|
|
||||||
* En producción debe estar desactivado.
|
|
||||||
*/
|
|
||||||
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
|
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -168,17 +163,43 @@ export function getExportUrl(orcidId, format) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Descarga una exportación como Blob (para forzar descarga programática).
|
* Descarga una exportación como Blob (para forzar descarga programática).
|
||||||
|
*
|
||||||
|
* `publicationIds` es opcional; si se pasa un array no vacío, el backend
|
||||||
|
* filtra el export a sólo esas publicaciones (exportación selectiva). Si
|
||||||
|
* se omite o va vacío/null, se exporta el conjunto completo.
|
||||||
|
*
|
||||||
|
* Usamos POST (no GET) porque los IDs pueden ser cientos y no caben
|
||||||
|
* cómodamente en la query-string.
|
||||||
|
*
|
||||||
* Lanza `ApiError` en fallo.
|
* Lanza `ApiError` en fallo.
|
||||||
*/
|
*/
|
||||||
export async function downloadExport(orcidId, format, { signal } = {}) {
|
export async function downloadExport(
|
||||||
|
orcidId,
|
||||||
|
format,
|
||||||
|
{ signal, publicationIds } = {},
|
||||||
|
) {
|
||||||
if (USE_MOCKS) {
|
if (USE_MOCKS) {
|
||||||
await mockExport(format);
|
await mockExport(format);
|
||||||
return { blob: null, url: getExportUrl(orcidId, format) };
|
return { blob: null, url: getExportUrl(orcidId, format) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = getExportUrl(orcidId, format);
|
const url = getExportUrl(orcidId, format);
|
||||||
|
const ids =
|
||||||
|
Array.isArray(publicationIds) && publicationIds.length > 0
|
||||||
|
? publicationIds
|
||||||
|
: null;
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, { signal });
|
response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "*/*",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ publication_ids: ids }),
|
||||||
|
});
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (cause?.name === "AbortError") throw cause;
|
if (cause?.name === "AbortError") throw cause;
|
||||||
throw new ApiError("No se pudo contactar con el servidor.", {
|
throw new ApiError("No se pudo contactar con el servidor.", {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), '')
|
const env = loadEnv(mode, import.meta.dirname, '')
|
||||||
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000'
|
const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user