diff --git a/CHANGELOG.md b/CHANGELOG.md
index c907474..4717bbf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog - project.linkedcorp.net
+## [2.19.0] - 2026-10-21
+### Added
+- Panel: selector **Tarjetas (n/7)** junto al título, con una casilla por tarjeta para
+ mostrarla u ocultarla. La elección se recuerda por usuario en el navegador (sobrevive a
+ recargar y a cerrar sesión). Nuevo componente `CheckListDropdown` (selección múltiple con
+ casillas, mismo estilo que ComboBox).
+
## [2.18.3] - 2026-10-21
### Fixed
- Después de una actualización, un F5 normal (sin Ctrl) mostraba la versión anterior hasta la
diff --git a/VERSION b/VERSION
index 2645281..ef0f38a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.18.3
+2.19.0
diff --git a/backend/package.json b/backend/package.json
index ef191c1..15ffb30 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
- "version": "2.18.3",
+ "version": "2.19.0",
"private": true,
"type": "module",
"main": "src/server.js",
diff --git a/frontend/package.json b/frontend/package.json
index 5373c77..530ebd3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
- "version": "2.18.3",
+ "version": "2.19.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/CheckListDropdown.jsx b/frontend/src/components/CheckListDropdown.jsx
new file mode 100644
index 0000000..7b34831
--- /dev/null
+++ b/frontend/src/components/CheckListDropdown.jsx
@@ -0,0 +1,100 @@
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { ChevronDown, Check } from 'lucide-react';
+
+// Desplegable de seleccion multiple con casillas -- mismo look y misma tecnica de posicion que
+// ComboBox (portal a body, position:fixed, se voltea hacia arriba si no cabe). A diferencia de
+// ComboBox, marcar una opcion NO cierra el panel.
+// options: [{ value, label }], values: array de values marcados.
+export function CheckListDropdown({ label, options, values, onChange, width = 220 }) {
+ const [open, setOpen] = useState(false);
+ const [pos, setPos] = useState(null);
+ const triggerRef = useRef(null);
+ const panelRef = useRef(null);
+
+ function openPanel() {
+ const rect = triggerRef.current.getBoundingClientRect();
+ setPos({ top: rect.bottom + 4, left: rect.left, anchorTop: rect.top, anchorBottom: rect.bottom, ready: false });
+ setOpen(true);
+ }
+
+ useLayoutEffect(() => {
+ if (!open || !pos || pos.ready || !panelRef.current) return;
+ const panelRect = panelRef.current.getBoundingClientRect();
+ const margin = 8;
+ let top = pos.top;
+ if (pos.anchorBottom + 4 + panelRect.height > window.innerHeight - margin) {
+ const openUpTop = pos.anchorTop - 4 - panelRect.height;
+ top = openUpTop >= margin ? openUpTop : Math.max(margin, window.innerHeight - margin - panelRect.height);
+ }
+ let left = pos.left;
+ if (left + panelRect.width > window.innerWidth - margin) left = window.innerWidth - margin - panelRect.width;
+ if (left < margin) left = margin;
+ setPos((p) => ({ ...p, top, left, ready: true }));
+ }, [open, pos]);
+
+ useEffect(() => {
+ if (!open) return;
+ function onDocMouseDown(e) {
+ if (triggerRef.current?.contains(e.target) || panelRef.current?.contains(e.target)) return;
+ setOpen(false);
+ }
+ function onScroll(e) {
+ if (e.target instanceof Node && panelRef.current?.contains(e.target)) return;
+ setOpen(false);
+ }
+ function onKey(e) { if (e.key === 'Escape') setOpen(false); }
+ document.addEventListener('mousedown', onDocMouseDown);
+ document.addEventListener('keydown', onKey);
+ const raf = requestAnimationFrame(() => {
+ window.addEventListener('scroll', onScroll, true);
+ window.addEventListener('resize', onScroll);
+ });
+ return () => {
+ cancelAnimationFrame(raf);
+ document.removeEventListener('mousedown', onDocMouseDown);
+ document.removeEventListener('keydown', onKey);
+ window.removeEventListener('scroll', onScroll, true);
+ window.removeEventListener('resize', onScroll);
+ };
+ }, [open]);
+
+ function toggle(value) {
+ onChange(values.includes(value) ? values.filter((v) => v !== value) : [...values, value]);
+ }
+
+ return (
+
+
+ {open && pos && createPortal(
+
+ {options.map((o) => {
+ const checked = values.includes(o.value);
+ return (
+
toggle(o.value)}
+ >
+ {checked && }
+ {o.label}
+
+ );
+ })}
+
,
+ document.body
+ )}
+
+ );
+}
diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js
index e8826da..3213a71 100644
--- a/frontend/src/i18n/messages.js
+++ b/frontend/src/i18n/messages.js
@@ -155,6 +155,8 @@ export const messages = {
dashboard_today_activities: 'Para hoy',
dashboard_average_progress: 'Avance promedio',
common_of: 'de',
+ dashboard_cards: 'Tarjetas',
+ dashboard_no_cards: 'No hay tarjetas seleccionadas. Elige cuáles mostrar en "Tarjetas".',
dashboard_projects_by_status: 'Proyectos por estado',
projects_title: 'Proyectos',
@@ -640,6 +642,8 @@ export const messages = {
dashboard_today_activities: 'Due today',
dashboard_average_progress: 'Average progress',
common_of: 'of',
+ dashboard_cards: 'Cards',
+ dashboard_no_cards: 'No cards selected. Choose which ones to show in "Cards".',
dashboard_projects_by_status: 'Projects by status',
projects_title: 'Projects',
diff --git a/frontend/src/pages/Dashboard/DashboardPage.jsx b/frontend/src/pages/Dashboard/DashboardPage.jsx
index aa1b281..e7214a3 100644
--- a/frontend/src/pages/Dashboard/DashboardPage.jsx
+++ b/frontend/src/pages/Dashboard/DashboardPage.jsx
@@ -1,10 +1,12 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useUi } from '../../context/ui-context.jsx';
+import { useAuth } from '../../context/auth-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { getSummary } from '../../services/dashboard.js';
import { KpiCard } from '../../components/KpiCard.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
+import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
import { ProgressBar, DueBadge } from '../../components/StatusBadge.jsx';
const ROW_STYLE = { display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)', cursor: 'pointer' };
@@ -19,6 +21,27 @@ function ratioOf(part, total) {
const BAR_COL = { width: 120, flex: 'none' };
const BADGE_COL = { width: 96, flex: 'none', display: 'flex', justifyContent: 'flex-end' };
+// Orden fijo de las tarjetas; el selector solo decide cuales se ven.
+const CARD_KEYS = ['active', 'overdue', 'dueSoon', 'pending', 'overdueActivities', 'today', 'average'];
+
+// Tarjetas visibles, persistidas por usuario en este navegador (sobrevive a recargar/cerrar
+// sesion). Clave por usuario para que dos personas en el mismo equipo no se pisen.
+function useVisibleCards(userId) {
+ const key = `plc_dashboard_cards_${userId || 'anon'}`;
+ const [visible, setVisible] = useState(() => {
+ try {
+ const saved = JSON.parse(localStorage.getItem(key));
+ if (Array.isArray(saved)) return saved.filter((k) => CARD_KEYS.includes(k));
+ } catch { /* sin storage o JSON invalido: todas visibles */ }
+ return CARD_KEYS;
+ });
+ function update(next) {
+ setVisible(next);
+ try { localStorage.setItem(key, JSON.stringify(next)); } catch { /* solo en memoria */ }
+ }
+ return [visible, update];
+}
+
const dateOnly = (d) => (d ? String(d).slice(0, 10) : '—');
function ProjectRows({ projects, onOpen }) {
@@ -58,9 +81,11 @@ function ActivityRows({ activities, onOpen }) {
export default function DashboardPage() {
const { t } = useUi();
const navigate = useNavigate();
+ const { user } = useAuth();
const { data } = useAsync(getSummary, []);
// Tarjeta cuyo detalle se muestra en el popup (null = cerrado).
const [detail, setDetail] = useState(null);
+ const [visibleCards, setVisibleCards] = useVisibleCards(user?.id);
if (!data) return {t('common_loading')}
;
const { kpis, projectsByStatus } = data;
@@ -91,40 +116,48 @@ export default function DashboardPage() {
const cardProps = (key) => ({ onClick: () => setDetail(key), selected: detail === key });
const openFromPopup = (path) => { setDetail(null); navigate(path); };
+ const CARDS = {
+ active: {
+ label: t('dashboard_active_projects'), value: `${active.pct}%`,
+ gauge: { ratio: active.ratio, color: 'accent', caption: of(kpis.activeProjects, nonCancelledTotal) },
+ },
+ overdue: {
+ label: t('dashboard_overdue_projects'), value: `${overdue.pct}%`,
+ gauge: { ratio: overdue.ratio, color: 'danger', caption: of(kpis.overdueProjects, activeTotal) },
+ },
+ dueSoon: { label: t('dashboard_due_soon_projects'), value: kpis.dueSoonProjects },
+ pending: {
+ label: t('dashboard_pending_activities'), value: `${pending.pct}%`,
+ gauge: { ratio: pending.ratio, color: 'warning', caption: of(kpis.pendingActivities, activitiesTotal) },
+ },
+ overdueActivities: {
+ label: t('dashboard_overdue_activities'), value: `${overdueAct.pct}%`,
+ gauge: { ratio: overdueAct.ratio, color: 'danger', caption: of(kpis.overdueActivities, activitiesStrictTotal) },
+ },
+ today: { label: t('dashboard_today_activities'), value: kpis.todayActivities },
+ average: {
+ label: t('dashboard_average_progress'), value: `${kpis.averageProgress}%`,
+ gauge: { ratio: Number(kpis.averageProgress) / 100, color: 'success' },
+ },
+ };
+
return (
-
{t('dashboard_title')}
-
-
-
-
-
-
-
-
-
+ {t('dashboard_title')}
+ ({ value: k, label: CARDS[k].label }))}
+ values={visibleCards}
+ onChange={setVisibleCards}
/>
+
+ {CARD_KEYS.filter((k) => visibleCards.includes(k)).map((k) => )}
+
+ {visibleCards.length === 0 &&
{t('dashboard_no_cards')}
}
+
{t('dashboard_projects_by_status')}
{projectsByStatus.map((s) => (
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index 36be11d..df111f5 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -162,6 +162,12 @@ button { font-family: inherit; }
padding: 7px 10px; border-radius: var(--r-sm); cursor: pointer; font-size: .82rem;
}
.combobox-option:hover, .combobox-option.active { background: rgb(var(--accent) / .14); }
+.checklist-option { display: flex; align-items: center; gap: 8px; }
+.checklist-box {
+ width: 16px; height: 16px; flex: none; border-radius: 4px; border: 1px solid rgb(var(--line));
+ display: flex; align-items: center; justify-content: center; color: #fff;
+}
+.checklist-box.checked { background: rgb(var(--accent)); border-color: rgb(var(--accent)); }
.combobox-empty { padding: 10px; color: rgb(var(--muted)); font-size: .78rem; }
/* --- Listado / DataGrid --- */