From 2bb13091333a23464a399c43ec3d455b6d80343c Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 24 Apr 2026 10:40:28 +0200 Subject: [PATCH 1/2] feat: update environment configuration and enhance API integration - Remove versioning from docker-compose.yml - Enhance Vite configuration to support API proxying for development - Improve error handling and data normalization in API service - Add fallback values in PublicationsTable and ResearcherCard components - Update sync functionality in DashboardPage to handle backend responses more effectively - Refactor mockSyncResearcher to simulate backend response structure --- docker-compose.yml | 2 - frontend/.env.example | 22 +++- .../dashboard/PublicationsTable.jsx | 33 +++-- .../components/dashboard/ResearcherCard.jsx | 14 ++- frontend/src/pages/DashboardPage.jsx | 21 +++- frontend/src/services/api.js | 117 +++++++++++++----- frontend/src/services/mocks.js | 10 +- frontend/src/utils/formatters.js | 10 +- frontend/vite.config.js | 25 +++- 9 files changed, 182 insertions(+), 72 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 06b2a3d..69cda12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: backend: diff --git a/frontend/.env.example b/frontend/.env.example index 8b9eb8c..9195d1b 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,7 +1,17 @@ -# Base URL of the FastAPI backend (no trailing slash). -# Example for local dev: http://localhost:8000 -VITE_API_URL=http://localhost:8000 +# URL base del backend FastAPI (sin barra final). +# +# Déjalo VACÍO en desarrollo para que las peticiones pasen por el proxy +# de Vite (ver vite.config.js). El proxy reenvía /researchers y /health +# al destino indicado en VITE_API_PROXY_TARGET. +# +# En producción apunta directamente al backend, p. ej. +# VITE_API_URL=https://api.midominio.com +VITE_API_URL= -# Set to "true" while the backend is not yet implemented. -# All API calls will be served by src/services/mocks.js instead of `fetch`. -VITE_USE_MOCKS=true +# Solo para dev: destino al que el proxy de Vite reenvía las peticiones. +# Cambia a http://backend:8000 si ejecutas el frontend dentro de docker-compose. +VITE_API_PROXY_TARGET=http://localhost:8000 + +# Pon "true" SOLO si el backend no está disponible y quieres trabajar +# con los fixtures de src/services/mocks.js. En producción debe estar a "false". +VITE_USE_MOCKS=false diff --git a/frontend/src/components/dashboard/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx index dce58e4..2a56fb2 100644 --- a/frontend/src/components/dashboard/PublicationsTable.jsx +++ b/frontend/src/components/dashboard/PublicationsTable.jsx @@ -59,9 +59,10 @@ export function PublicationsTable({ const rows = needle ? publications.filter( (p) => - p.title.toLowerCase().includes(needle) || - p.journal.toLowerCase().includes(needle) || - String(p.publication_year).includes(needle), + (p.title ?? "").toLowerCase().includes(needle) || + (p.journal ?? "").toLowerCase().includes(needle) || + String(p.publication_year ?? "").includes(needle) || + (p.doi ?? "").toLowerCase().includes(needle), ) : publications; return sortPublications(rows, sortKey, sortDir); @@ -153,20 +154,26 @@ export function PublicationsTable({ {pub.title} - {pub.journal} + {pub.journal || "—"} - {pub.publication_year} + {pub.publication_year ?? "—"} - - {pub.doi} - + {pub.doi ? ( + + {pub.doi} + + ) : ( + + — + + )} diff --git a/frontend/src/components/dashboard/ResearcherCard.jsx b/frontend/src/components/dashboard/ResearcherCard.jsx index 88ef7ee..546971a 100644 --- a/frontend/src/components/dashboard/ResearcherCard.jsx +++ b/frontend/src/components/dashboard/ResearcherCard.jsx @@ -16,7 +16,7 @@ export function ResearcherCard({ researcher, actions = null }) {

- {researcher.name} + {researcher.name || "Investigador sin nombre"}

@@ -25,10 +25,14 @@ export function ResearcherCard({ researcher, actions = null }) { {researcher.orcid_id}
- · - - {researcher.affiliation} - + {researcher.affiliation && ( + <> + · + + {researcher.affiliation} + + + )}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index fa8d3a5..43f4bb9 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -83,12 +83,25 @@ export function DashboardPage() { async function handleSync() { setSyncStatus("loading"); try { - const updated = await syncResearcher(orcid); - if (updated) setResearcher(updated); - await loadPublications(); + const summary = await syncResearcher(orcid); + + if (summary?.status === "error") { + 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()]); + setSyncStatus("success"); + const total = summary?.total ?? 0; + const nuevos = summary?.new_records ?? 0; + const actualizados = summary?.updated_records ?? 0; toast.success("Sincronización completada", { - description: "Las publicaciones se han actualizado desde ORCID.", + description: + total > 0 + ? `${nuevos} nuevas · ${actualizados} actualizadas (${total} total).` + : summary?.message ?? "Sin cambios desde la última sincronización.", }); setTimeout(() => setSyncStatus("idle"), SUCCESS_FLASH_MS); } catch (err) { diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 7fcd1f0..52a693a 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,13 +1,22 @@ /** - * Thin API client for the FastAPI backend. + * Cliente HTTP del frontend contra la API FastAPI. * - * Every call returns parsed JSON (or a Blob for file downloads) and throws - * an `ApiError` on non-2xx responses so callers can decide how to surface it - * (toast, inline error, retry, etc.). + * Cada función devuelve el JSON ya parseado (o un Blob para descargas) + * y lanza `ApiError` en respuestas no 2xx, de forma que cada pantalla + * decide cómo mostrarlo (toast, error inline, reintento, …). * - * The base URL is injected at build time via `VITE_API_URL` - * (see `.env.example`). During development, leaving it blank falls back to - * same-origin requests, which plays well with a Vite proxy. + * La URL base se inyecta en build via `VITE_API_URL` (ver `.env.example`). + * En desarrollo la dejamos en blanco para que las peticiones pasen por + * el proxy de Vite (ver `vite.config.js`) y así eludir CORS mientras el + * backend no lo tenga configurado. + * + * Contrato real del backend (prefijo de router: `/researchers`): + * - POST /researchers/?orcid_id=XXXX-XXXX-XXXX-XXXX (crea/upsert) + * - GET /researchers/{orcid_id} + * - POST /researchers/{orcid_id}/sync + * - GET /researchers/{orcid_id}/publications + * - GET /researchers/{orcid_id}/export/sword.xml + * - GET /researchers/{orcid_id}/export/sword.zip */ import { @@ -20,9 +29,9 @@ import { const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); /** - * When the backend is not available yet, set `VITE_USE_MOCKS=true` in your - * `.env.local` to route every call through `mocks.js`. In production this - * flag MUST be unset. + * 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"; @@ -42,7 +51,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) { signal, headers: { Accept: "application/json", - ...(body ? { "Content-Type": "application/json" } : {}), + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...headers, }, }; @@ -52,6 +61,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) { try { response = await fetch(url, init); } catch (cause) { + if (cause?.name === "AbortError") throw cause; throw new ApiError("No se pudo contactar con el servidor.", { status: 0, payload: { cause: String(cause) }, @@ -63,7 +73,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) { try { payload = await response.json(); } catch { - /* response had no JSON body */ + /* sin cuerpo JSON */ } const detail = payload?.detail ?? payload?.message ?? response.statusText ?? "Error"; @@ -79,47 +89,86 @@ async function request(path, { method = "GET", body, signal, headers } = {}) { return response; } +/* ───────────────────────────── Mapeos ────────────────────────────── */ + +/** + * Adapta el esquema del backend (`pub_year`, campos opcionalmente `null`) + * al que espera la UI (`publication_year`, strings seguras para filtrar). + */ +function normalizePublication(p) { + return { + id: p.id, + put_code: p.put_code ?? null, + title: p.title || "Sin título", + journal: p.journal || "", + doi: p.doi || "", + publication_year: p.pub_year ?? null, + type: p.type || null, + hash_fingerprint: p.hash_fingerprint ?? null, + last_modified: p.last_modified ?? null, + }; +} + /* ───────────────────────────── Endpoints ─────────────────────────────── */ -/** POST /api/orcid/validate — validates an ORCID iD and returns the researcher. */ -export function validateOrcid(orcidId, { signal } = {}) { +/** + * Asegura que el investigador existe en el backend y devuelve su ficha + * completa. + * + * Como el backend no expone un endpoint de validación puro, hacemos: + * 1. POST /researchers/?orcid_id=... (idempotente: crea o devuelve el + * existente; valida formato + dígito de control en el servidor). + * 2. GET /researchers/{orcid_id} (para recuperar el objeto completo: + * name, last_sync_at, etc.). + */ +export async function validateOrcid(orcidId, { signal } = {}) { if (USE_MOCKS) return mockValidateOrcid(orcidId); - return request("/api/orcid/validate", { - method: "POST", - body: { orcid_id: orcidId }, - signal, - }); + + await request( + `/researchers/?orcid_id=${encodeURIComponent(orcidId)}`, + { method: "POST", signal }, + ); + return request(`/researchers/${encodeURIComponent(orcidId)}`, { signal }); } -/** GET /api/researchers/{orcid}/publications — lists ORCID works. */ -export function getPublications(orcidId, { signal } = {}) { +/** GET /researchers/{orcid}/publications — normalizado para la UI. */ +export async function getPublications(orcidId, { signal } = {}) { if (USE_MOCKS) return mockGetPublications(orcidId); - return request( - `/api/researchers/${encodeURIComponent(orcidId)}/publications`, + + const raw = await request( + `/researchers/${encodeURIComponent(orcidId)}/publications`, { signal }, ); + return Array.isArray(raw) ? raw.map(normalizePublication) : []; } -/** POST /api/researchers/{orcid}/sync — triggers ORCID re-harvest. */ +/** + * POST /researchers/{orcid}/sync — dispara el re-harvest desde ORCID. + * + * El backend devuelve un resumen del job (`{status, message, new_records, + * updated_records, total, researcher}`), no el researcher completo. + * El caller debe refetch-ear el researcher y sus publicaciones. + */ export function syncResearcher(orcidId, { signal } = {}) { if (USE_MOCKS) return mockSyncResearcher(orcidId); - return request(`/api/researchers/${encodeURIComponent(orcidId)}/sync`, { + + return request(`/researchers/${encodeURIComponent(orcidId)}/sync`, { method: "POST", signal, }); } /** - * Builds the public export URL so links/anchors can download files directly - * without going through `fetch`. Used by the export dropdown. + * Construye la URL pública de exportación para enlaces directos + * (sin pasar por `fetch`). La usa el dropdown de exportación. */ export function getExportUrl(orcidId, format) { - return `${BASE_URL}/api/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`; + return `${BASE_URL}/researchers/${encodeURIComponent(orcidId)}/export/sword.${format}`; } /** - * Downloads an export as a Blob (useful when we want to trigger a - * programmatic file download). Falls back to `ApiError` on failure. + * Descarga una exportación como Blob (para forzar descarga programática). + * Lanza `ApiError` en fallo. */ export async function downloadExport(orcidId, format, { signal } = {}) { if (USE_MOCKS) { @@ -131,15 +180,17 @@ export async function downloadExport(orcidId, format, { signal } = {}) { try { response = await fetch(url, { signal }); } catch (cause) { + if (cause?.name === "AbortError") throw cause; throw new ApiError("No se pudo contactar con el servidor.", { status: 0, payload: { cause: String(cause) }, }); } if (!response.ok) { - throw new ApiError(`No se pudo exportar el fichero ${format.toUpperCase()}.`, { - status: response.status, - }); + throw new ApiError( + `No se pudo exportar el fichero ${format.toUpperCase()}.`, + { status: response.status }, + ); } const blob = await response.blob(); return { blob, url }; diff --git a/frontend/src/services/mocks.js b/frontend/src/services/mocks.js index 85bc092..a730ac8 100644 --- a/frontend/src/services/mocks.js +++ b/frontend/src/services/mocks.js @@ -69,10 +69,14 @@ export async function mockGetPublications(/* orcidId */) { export async function mockSyncResearcher(orcidId) { await delay(1800); + // Imita el payload real del backend (resumen del SyncJob, no el researcher). return { - ...MOCK_RESEARCHER, - orcid_id: orcidId, - last_sync_at: new Date().toISOString(), + status: "ok", + message: "Sincronización completada correctamente.", + researcher: orcidId, + new_records: 0, + updated_records: MOCK_PUBLICATIONS.length, + total: MOCK_PUBLICATIONS.length, }; } diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js index 1092d88..34e01da 100644 --- a/frontend/src/utils/formatters.js +++ b/frontend/src/utils/formatters.js @@ -16,10 +16,14 @@ export function formatDate(iso) { /** * Builds researcher initials (max 2 chars) from a full name. + * Si el backend aún no conoce el nombre, devolvemos un guion como + * placeholder para no dejar el avatar vacío. */ -export function getInitials(name = "") { - return name - .trim() +export function getInitials(name) { + if (!name || typeof name !== "string") return "–"; + const trimmed = name.trim(); + if (!trimmed) return "–"; + return trimmed .split(/\s+/) .map((w) => w[0] ?? "") .slice(0, 2) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c4069b7..66f0f91 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,8 +1,27 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ -export default defineConfig({ - plugins: [react(), tailwindcss()], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const proxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8000' + + return { + plugins: [react(), tailwindcss()], + server: { + host: true, + port: 5173, + proxy: { + '/researchers': { + target: proxyTarget, + changeOrigin: true, + }, + '/health': { + target: proxyTarget, + changeOrigin: true, + }, + }, + }, + } }) From 63b95fb834d42c37289931de95256d6bce27d6e6 Mon Sep 17 00:00:00 2001 From: Alexis Date: Tue, 28 Apr 2026 12:02:11 +0200 Subject: [PATCH 2/2] 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 --- .../components/dashboard/ExportDropdown.jsx | 23 +- .../dashboard/PublicationsTable.jsx | 530 +++++++++++++++--- frontend/src/components/ui/Icons.jsx | 18 + frontend/src/pages/DashboardPage.jsx | 23 +- frontend/src/services/api.js | 35 +- frontend/vite.config.js | 2 +- 6 files changed, 535 insertions(+), 96 deletions(-) diff --git a/frontend/src/components/dashboard/ExportDropdown.jsx b/frontend/src/components/dashboard/ExportDropdown.jsx index cccf24b..3cbe68e 100644 --- a/frontend/src/components/dashboard/ExportDropdown.jsx +++ b/frontend/src/components/dashboard/ExportDropdown.jsx @@ -1,20 +1,22 @@ import { useEffect, useRef, useState } from "react"; import { ChevronDownIcon, + DocumentIcon, DownloadIcon, + PackageIcon, } from "../ui/Icons"; import { Spinner } from "../ui/Spinner"; const FORMATS = [ { format: "xml", - icon: "📄", + icon: , label: "SWORD XML", desc: "Metadatos en formato Atom", }, { format: "zip", - icon: "📦", + icon: , label: "Paquete ZIP", desc: "XML + ficheros adjuntos", }, @@ -28,7 +30,11 @@ const FORMATS = [ * `exportingFormat` (optional) lets the parent keep the button in a loading * 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 rootRef = useRef(null); @@ -43,12 +49,17 @@ export function ExportDropdown({ onExport, exportingFormat = null }) { }, []); const isBusy = Boolean(exportingFormat); + const hasSelection = selectedCount > 0; function handlePick(format) { setOpen(false); onExport(format); } + const idleLabel = hasSelection + ? `Exportar seleccionadas (${selectedCount})` + : "Exportar todas"; + return (
@@ -77,9 +88,7 @@ export function ExportDropdown({ onExport, exportingFormat = null }) { : "" }`} > - - {icon} - + {icon}
{label} diff --git a/frontend/src/components/dashboard/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx index 2a56fb2..89e4ac7 100644 --- a/frontend/src/components/dashboard/PublicationsTable.jsx +++ b/frontend/src/components/dashboard/PublicationsTable.jsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from "react"; -import { AlertIcon, SearchIcon } from "../ui/Icons"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon } from "../ui/Icons"; import { Spinner } from "../ui/Spinner"; import { Badge } from "../ui/Badge"; @@ -11,6 +11,9 @@ const COLUMNS = [ { key: "type", label: "Tipo" }, ]; +const PAGE_SIZE = 15; +const EMPTY_SELECTION = new Set(); + function SortIcon({ active, direction }) { const path = 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 - * error states are driven by the parent page so retries and toasts can be - * handled in one place. + * 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 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({ publications, loading = false, error = null, onRetry, + selectedIds = EMPTY_SELECTION, + onSelectedIdsChange, }) { 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(); - const rows = needle - ? publications.filter( - (p) => - (p.title ?? "").toLowerCase().includes(needle) || - (p.journal ?? "").toLowerCase().includes(needle) || - String(p.publication_year ?? "").includes(needle) || - (p.doi ?? "").toLowerCase().includes(needle), - ) - : publications; + 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, 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) { 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 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 (
{/* Toolbar */} -
-
-

- Publicaciones -

-

- {filtered.length} de {publications.length} resultados -

-
-
- setFilter(e.target.value)} - 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" - /> - - - +
+
+
+

+ 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-[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" + /> + + + +
+
+ + {filtersOpen && ( +
+
+ + +
+
+ + +
+ {hasYearFilter && ( + + )} + {availableYears.length === 0 && ( +

+ Aún no hay años disponibles. +

+ )} +
+ )}
{/* Body */} @@ -110,9 +375,21 @@ export function PublicationsTable({ ) : loading ? ( ) : ( - +
+ {COLUMNS.map((col) => ( ) : ( - filtered.map((pub, i) => ( - - - - - - - - )) + pageRows.map((pub, i) => { + const isSelected = selectedIds.has(pub.id); + return ( + + + + + + + + + ); + }) )}
e.stopPropagation()} + > + + - No se encontraron publicaciones con ese filtro. + No se encontraron publicaciones con los filtros aplicados.
- {pub.title} - - {pub.journal || "—"} - - {pub.publication_year ?? "—"} - - {pub.doi ? ( - - {pub.doi} - - ) : ( - - — - - )} - - -
{ + e.stopPropagation(); + toggleRow(pub.id); + }} + > + toggleRow(pub.id)} + ariaLabel={`Seleccionar publicación ${pub.title}`} + /> + + {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 (
diff --git a/frontend/src/components/ui/Icons.jsx b/frontend/src/components/ui/Icons.jsx index 67ec57b..ac50b72 100644 --- a/frontend/src/components/ui/Icons.jsx +++ b/frontend/src/components/ui/Icons.jsx @@ -94,6 +94,14 @@ export function SearchIcon({ size = 14, className = "" }) { ); } +export function FilterIcon({ size = 14, className = "" }) { + return ( + + + + ); +} + export function AlertIcon({ size = 16, className = "" }) { return ( @@ -102,3 +110,13 @@ export function AlertIcon({ size = 16, className = "" }) { ); } + +export function PackageIcon({ size = 18, className = "" }) { + return ( + + + + + + ); +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 43f4bb9..5d99ceb 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -36,6 +36,8 @@ export function DashboardPage() { const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success const [exportingFormat, setExportingFormat] = useState(null); + const [selectedIds, setSelectedIds] = useState(() => new Set()); + const loadResearcher = useCallback( async (signal) => { try { @@ -57,7 +59,16 @@ export function DashboardPage() { setPubsError(null); try { 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) { if (signal?.aborted) return; setPubsError(err); @@ -89,8 +100,6 @@ export function DashboardPage() { 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()]); setSyncStatus("success"); @@ -115,7 +124,10 @@ export function DashboardPage() { async function handleExport(format) { setExportingFormat(format); 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) { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); @@ -152,6 +164,7 @@ export function DashboardPage() { } @@ -167,6 +180,8 @@ export function DashboardPage() { loading={pubsLoading} error={pubsError} onRetry={() => loadPublications()} + selectedIds={selectedIds} + onSelectedIdsChange={setSelectedIds} />