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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [2.18.3] - 2026-10-21
|
||||||
### Fixed
|
### Fixed
|
||||||
- Después de una actualización, un F5 normal (sin Ctrl) mostraba la versión anterior hasta la
|
- Después de una actualización, un F5 normal (sin Ctrl) mostraba la versión anterior hasta la
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.18.3",
|
"version": "2.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.18.3",
|
"version": "2.19.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="combobox" style={{ width }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={triggerRef}
|
||||||
|
className="input"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
|
||||||
|
onClick={() => (open ? setOpen(false) : openPanel())}
|
||||||
|
>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
{open && pos && createPortal(
|
||||||
|
<div ref={panelRef} className="combobox-panel" style={{ top: pos.top, left: pos.left, width, visibility: pos.ready ? 'visible' : 'hidden' }}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const checked = values.includes(o.value);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={o.value}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
className="combobox-option checklist-option"
|
||||||
|
onClick={() => toggle(o.value)}
|
||||||
|
>
|
||||||
|
<span className={`checklist-box ${checked ? 'checked' : ''}`}>{checked && <Check size={12} strokeWidth={3} />}</span>
|
||||||
|
{o.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -155,6 +155,8 @@ export const messages = {
|
|||||||
dashboard_today_activities: 'Para hoy',
|
dashboard_today_activities: 'Para hoy',
|
||||||
dashboard_average_progress: 'Avance promedio',
|
dashboard_average_progress: 'Avance promedio',
|
||||||
common_of: 'de',
|
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',
|
dashboard_projects_by_status: 'Proyectos por estado',
|
||||||
|
|
||||||
projects_title: 'Proyectos',
|
projects_title: 'Proyectos',
|
||||||
@@ -640,6 +642,8 @@ export const messages = {
|
|||||||
dashboard_today_activities: 'Due today',
|
dashboard_today_activities: 'Due today',
|
||||||
dashboard_average_progress: 'Average progress',
|
dashboard_average_progress: 'Average progress',
|
||||||
common_of: 'of',
|
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',
|
dashboard_projects_by_status: 'Projects by status',
|
||||||
|
|
||||||
projects_title: 'Projects',
|
projects_title: 'Projects',
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync } from '../../hooks/useAsync.js';
|
import { useAsync } from '../../hooks/useAsync.js';
|
||||||
import { getSummary } from '../../services/dashboard.js';
|
import { getSummary } from '../../services/dashboard.js';
|
||||||
import { KpiCard } from '../../components/KpiCard.jsx';
|
import { KpiCard } from '../../components/KpiCard.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
|
||||||
import { ProgressBar, DueBadge } from '../../components/StatusBadge.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' };
|
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 BAR_COL = { width: 120, flex: 'none' };
|
||||||
const BADGE_COL = { width: 96, flex: 'none', display: 'flex', justifyContent: 'flex-end' };
|
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) : '—');
|
const dateOnly = (d) => (d ? String(d).slice(0, 10) : '—');
|
||||||
|
|
||||||
function ProjectRows({ projects, onOpen }) {
|
function ProjectRows({ projects, onOpen }) {
|
||||||
@@ -58,9 +81,11 @@ function ActivityRows({ activities, onOpen }) {
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const { data } = useAsync(getSummary, []);
|
const { data } = useAsync(getSummary, []);
|
||||||
// Tarjeta cuyo detalle se muestra en el popup (null = cerrado).
|
// Tarjeta cuyo detalle se muestra en el popup (null = cerrado).
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [visibleCards, setVisibleCards] = useVisibleCards(user?.id);
|
||||||
|
|
||||||
if (!data) return <div className="text-muted">{t('common_loading')}</div>;
|
if (!data) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
const { kpis, projectsByStatus } = data;
|
const { kpis, projectsByStatus } = data;
|
||||||
@@ -91,40 +116,48 @@ export default function DashboardPage() {
|
|||||||
const cardProps = (key) => ({ onClick: () => setDetail(key), selected: detail === key });
|
const cardProps = (key) => ({ onClick: () => setDetail(key), selected: detail === key });
|
||||||
const openFromPopup = (path) => { setDetail(null); navigate(path); };
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('dashboard_title')}</h2>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||||
|
<h2 className="page-title mt-0" style={{ margin: 0 }}>{t('dashboard_title')}</h2>
|
||||||
<div className="kpi-grid">
|
<CheckListDropdown
|
||||||
<KpiCard
|
label={`${t('dashboard_cards')} (${visibleCards.length}/${CARD_KEYS.length})`}
|
||||||
label={t('dashboard_active_projects')} value={`${active.pct}%`}
|
options={CARD_KEYS.map((k) => ({ value: k, label: CARDS[k].label }))}
|
||||||
gauge={{ ratio: active.ratio, color: 'accent', caption: of(kpis.activeProjects, nonCancelledTotal) }}
|
values={visibleCards}
|
||||||
{...cardProps('active')}
|
onChange={setVisibleCards}
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label={t('dashboard_overdue_projects')} value={`${overdue.pct}%`}
|
|
||||||
gauge={{ ratio: overdue.ratio, color: 'danger', caption: of(kpis.overdueProjects, activeTotal) }}
|
|
||||||
{...cardProps('overdue')}
|
|
||||||
/>
|
|
||||||
<KpiCard label={t('dashboard_due_soon_projects')} value={kpis.dueSoonProjects} {...cardProps('dueSoon')} />
|
|
||||||
<KpiCard
|
|
||||||
label={t('dashboard_pending_activities')} value={`${pending.pct}%`}
|
|
||||||
gauge={{ ratio: pending.ratio, color: 'warning', caption: of(kpis.pendingActivities, activitiesTotal) }}
|
|
||||||
{...cardProps('pending')}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label={t('dashboard_overdue_activities')} value={`${overdueAct.pct}%`}
|
|
||||||
gauge={{ ratio: overdueAct.ratio, color: 'danger', caption: of(kpis.overdueActivities, activitiesStrictTotal) }}
|
|
||||||
{...cardProps('overdueActivities')}
|
|
||||||
/>
|
|
||||||
<KpiCard label={t('dashboard_today_activities')} value={kpis.todayActivities} {...cardProps('today')} />
|
|
||||||
<KpiCard
|
|
||||||
label={t('dashboard_average_progress')} value={`${kpis.averageProgress}%`}
|
|
||||||
gauge={{ ratio: Number(kpis.averageProgress) / 100, color: 'success' }}
|
|
||||||
{...cardProps('average')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="kpi-grid">
|
||||||
|
{CARD_KEYS.filter((k) => visibleCards.includes(k)).map((k) => <KpiCard key={k} {...CARDS[k]} {...cardProps(k)} />)}
|
||||||
|
</div>
|
||||||
|
{visibleCards.length === 0 && <div className="text-muted" style={{ marginBottom: 20 }}>{t('dashboard_no_cards')}</div>}
|
||||||
|
|
||||||
<div className="panel-card">
|
<div className="panel-card">
|
||||||
<h3>{t('dashboard_projects_by_status')}</h3>
|
<h3>{t('dashboard_projects_by_status')}</h3>
|
||||||
{projectsByStatus.map((s) => (
|
{projectsByStatus.map((s) => (
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ button { font-family: inherit; }
|
|||||||
padding: 7px 10px; border-radius: var(--r-sm); cursor: pointer; font-size: .82rem;
|
padding: 7px 10px; border-radius: var(--r-sm); cursor: pointer; font-size: .82rem;
|
||||||
}
|
}
|
||||||
.combobox-option:hover, .combobox-option.active { background: rgb(var(--accent) / .14); }
|
.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; }
|
.combobox-empty { padding: 10px; color: rgb(var(--muted)); font-size: .78rem; }
|
||||||
|
|
||||||
/* --- Listado / DataGrid --- */
|
/* --- Listado / DataGrid --- */
|
||||||
|
|||||||
Reference in New Issue
Block a user