From 8ed1d63505163b997ec89517a54b286fb27d4350 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 21 Oct 2026 09:43:42 -0500 Subject: [PATCH] feat: version 2.19.0 -- Panel: selector de tarjetas visibles Desplegable con casillas junto al titulo para mostrar/ocultar cada tarjeta, persistido por usuario en localStorage. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ VERSION | 2 +- backend/package.json | 2 +- frontend/package.json | 2 +- frontend/src/components/CheckListDropdown.jsx | 100 ++++++++++++++++++ frontend/src/i18n/messages.js | 4 + .../src/pages/Dashboard/DashboardPage.jsx | 91 +++++++++++----- frontend/src/styles/app.css | 6 ++ 8 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/CheckListDropdown.jsx 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 --- */