feat: version 2.11.0 -- contadores de dependencias/comentarios/documentos/miembros en listados

Actividades (dentro de un proyecto): nuevas columnas con la cantidad de dependencias,
comentarios y documentos de cada fila -- subconsultas correlacionadas agregadas al
LIST_SELECT existente (mismo patron que share_count de notas/proyectos), siempre numericas
(0 si no hay, nunca oculto).

Proyectos: nuevas columnas con la cantidad de miembros, actividades completas al 100% sobre
el total (ej. "1/4", mismo patron pendientes/total que ya usa la pestaña de tareas de una
actividad), comentarios y documentos.

Nuevo componente CountBadge (icono + numero) reutilizado en ambos listados -- a diferencia de
SharedWithIcon/Tabs que ocultan el badge en 0, este siempre lo muestra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-14 11:40:07 -05:00
parent 481ba008da
commit 4520abe96f
10 changed files with 76 additions and 12 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog - project.linkedcorp.net
## [2.11.0] - 2026-10-14
### Added
- Listado de Actividades (dentro de un proyecto): columnas nuevas con el número de
dependencias, comentarios y documentos de cada actividad (siempre visible, 0 si no hay).
- Listado de Proyectos: columnas nuevas con el número de miembros, actividades completas al
100% sobre el total (ej. "1/4"), comentarios y documentos de cada proyecto (siempre visible,
0 si no hay).
## [2.10.7] - 2026-10-12
### Changed
- Tarjetas de Notas: la fecha (abajo a la izquierda) queda al mismo nivel que los íconos de
+1 -1
View File
@@ -1 +1 @@
2.10.7
2.11.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "2.10.7",
"version": "2.11.0",
"private": true,
"type": "module",
"main": "src/server.js",
+18 -4
View File
@@ -6,27 +6,41 @@ const LIST_SELECT = `
a.status_id, st.name AS status_name, st.color AS status_color, st.is_closed AS status_is_closed,
a.start_date, a.due_date, a.actual_end_date, a.progress_pct, a.is_milestone,
a.estimated_hours, a.sort_order, a.created_by, a.created_at, a.updated_at,
a.deleted_at, a.deleted_by
a.deleted_at, a.deleted_by,
(SELECT COUNT(*) FROM activity_dependencies d WHERE d.activity_id = a.id) AS dependencies_count,
(SELECT COUNT(*) FROM comments c WHERE c.entity_type = 'activity' AND c.entity_id = a.id) AS comments_count,
(SELECT COUNT(*) FROM attachments att WHERE att.entity_type = 'activity' AND att.entity_id = a.id) AS documents_count
FROM activities a
JOIN priorities pr ON pr.id = a.priority_id
JOIN activity_statuses st ON st.id = a.status_id
LEFT JOIN users u ON u.id = a.responsible_user_id
`;
// Los COUNT(*) de subconsultas correlacionadas vuelven de mysql2 como string/BigInt-like --
// se castean a Number aqui para que el frontend los reciba como numero real (orden, comparar).
function castCounts(row) {
return {
...row,
dependencies_count: Number(row.dependencies_count),
comments_count: Number(row.comments_count),
documents_count: Number(row.documents_count),
};
}
export async function findByProject(projectId) {
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? AND a.deleted_at IS NULL ORDER BY a.sort_order, a.id`, [projectId]);
return rows;
return rows.map(castCounts);
}
export async function findTrashByProject(projectId) {
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? AND a.deleted_at IS NOT NULL ORDER BY a.id DESC`, [projectId]);
return rows;
return rows.map(castCounts);
}
export async function findById(id, { includeDeleted = false } = {}) {
const where = includeDeleted ? 'a.id = ?' : 'a.id = ? AND a.deleted_at IS NULL';
const [rows] = await pool.query(`${LIST_SELECT} WHERE ${where}`, [id]);
return rows[0] || null;
return rows[0] ? castCounts(rows[0]) : null;
}
export async function create(projectId, data) {
+22 -3
View File
@@ -9,7 +9,12 @@ const LIST_SELECT = `
p.responsible_department_id, sd.name AS responsible_department_name,
p.start_date, p.estimated_end_date, p.actual_end_date, p.progress_pct,
p.risk_level, p.notes, p.created_by, p.created_at, p.updated_at, p.deleted_at, p.deleted_by,
(SELECT COUNT(*) FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id <> p.owner_user_id) AS shared_count
(SELECT COUNT(*) FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id <> p.owner_user_id) AS shared_count,
(SELECT COUNT(*) FROM project_members pm2 WHERE pm2.project_id = p.id) AS members_count,
(SELECT COUNT(*) FROM activities act WHERE act.project_id = p.id AND act.deleted_at IS NULL) AS activities_total,
(SELECT COUNT(*) FROM activities act2 WHERE act2.project_id = p.id AND act2.deleted_at IS NULL AND act2.progress_pct >= 100) AS activities_complete,
(SELECT COUNT(*) FROM comments c WHERE c.entity_type = 'project' AND c.entity_id = p.id) AS comments_count,
(SELECT COUNT(*) FROM attachments att WHERE att.entity_type = 'project' AND att.entity_id = p.id) AS documents_count
FROM projects p
JOIN priorities pr ON pr.id = p.priority_id
JOIN project_statuses ps ON ps.id = p.status_id
@@ -19,6 +24,20 @@ const LIST_SELECT = `
LEFT JOIN departments sd ON sd.id = p.responsible_department_id
`;
// Los COUNT(*) de subconsultas correlacionadas vuelven de mysql2 como string/BigInt-like --
// se castean a Number aqui para que el frontend los reciba como numero real (orden, comparar).
function castCounts(row) {
return {
...row,
shared_count: Number(row.shared_count),
members_count: Number(row.members_count),
activities_total: Number(row.activities_total),
activities_complete: Number(row.activities_complete),
comments_count: Number(row.comments_count),
documents_count: Number(row.documents_count),
};
}
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash } = {}) {
const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
const params = [];
@@ -33,13 +52,13 @@ export async function findAll({ statusId, priorityId, ownerUserId, memberUserId,
}
const where = `WHERE ${clauses.join(' AND ')}`;
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
return rows;
return rows.map(castCounts);
}
export async function findById(id, { includeDeleted = false } = {}) {
const where = includeDeleted ? 'p.id = ?' : 'p.id = ? AND p.deleted_at IS NULL';
const [rows] = await pool.query(`${LIST_SELECT} WHERE ${where}`, [id]);
return rows[0] || null;
return rows[0] ? castCounts(rows[0]) : null;
}
export async function softDelete(id, userId) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "2.10.7",
"version": "2.11.0",
"type": "module",
"scripts": {
"dev": "vite",
+11
View File
@@ -0,0 +1,11 @@
// Contador chico con icono para columnas de listado (dependencias/comentarios/documentos/
// miembros). Siempre muestra el numero, incluido 0 -- a diferencia de SharedWithIcon/Tabs que
// ocultan el badge en 0, aca el pedido es que el contador siempre sea visible.
export function CountBadge({ icon: Icon, count, title }) {
return (
<span className="count-badge" title={title}>
<Icon size={14} />
{count}
</span>
);
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw } from 'lucide-react';
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw, Link2, MessageSquare, Paperclip } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
@@ -19,6 +19,7 @@ import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge.jsx';
import { dueStatus } from '../../utils/dueStatus.js';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { CountBadge } from '../../components/CountBadge.jsx';
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
@@ -212,6 +213,9 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
</div>
) },
{ key: 'progress_pct', label: t('activity_progress'), render: (r) => <div style={{ width: 100 }}><ProgressBar value={Number(r.progress_pct)} /></div> },
{ key: 'dependencies_count', label: t('activity_dependencies'), render: (r) => <CountBadge icon={Link2} count={r.dependencies_count} title={t('activity_dependencies')} /> },
{ key: 'comments_count', label: t('tab_comments'), render: (r) => <CountBadge icon={MessageSquare} count={r.comments_count} title={t('tab_comments')} /> },
{ key: 'documents_count', label: t('tab_documents'), render: (r) => <CountBadge icon={Paperclip} count={r.documents_count} title={t('tab_documents')} /> },
{ key: 'updated_at', label: t('activity_updated_at'), render: (r) => <span style={{ whiteSpace: 'nowrap' }}>{String(r.updated_at).slice(0, 16)}</span> },
];
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Save, Trash2, RotateCcw, ArrowLeft } from 'lucide-react';
import { Plus, Save, Trash2, RotateCcw, ArrowLeft, Users, ListChecks, MessageSquare, Paperclip } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
@@ -20,6 +20,7 @@ import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { SharedWithIcon } from '../../components/SharedWithIcon.jsx';
import { CountBadge } from '../../components/CountBadge.jsx';
const CAN_DELETE_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
@@ -169,7 +170,13 @@ export default function ProyectosPage() {
{ key: 'status_name', label: t('common_status'), render: (r) => <StatusBadge name={r.status_name} color={r.status_color} /> },
{ key: 'priority_name', label: t('common_priority'), sortValue: (r) => r.priority_level, render: (r) => <StatusBadge name={r.priority_name} color={r.priority_color} /> },
{ key: 'owner_name', label: t('project_owner') },
{ key: 'members_count', label: t('project_members'), render: (r) => <CountBadge icon={Users} count={r.members_count} title={t('project_members')} /> },
{ key: 'progress_pct', label: t('project_progress'), render: (r) => <div style={{ width: 90 }}><ProgressBar value={Number(r.progress_pct)} /></div> },
{ key: 'activities_progress', label: t('tab_activities'), sortValue: (r) => (r.activities_total ? r.activities_complete / r.activities_total : 0), render: (r) => (
<span className="count-badge" title={t('tab_activities')}><ListChecks size={14} />{r.activities_complete}/{r.activities_total}</span>
) },
{ key: 'comments_count', label: t('tab_comments'), render: (r) => <CountBadge icon={MessageSquare} count={r.comments_count} title={t('tab_comments')} /> },
{ key: 'documents_count', label: t('tab_documents'), render: (r) => <CountBadge icon={Paperclip} count={r.documents_count} title={t('tab_documents')} /> },
{ key: 'shared', label: '', sortable: false, render: (r) => <SharedWithIcon projectId={r.id} count={r.shared_count} /> },
{ key: 'risk_level', label: t('project_risk'), render: (r) => <Badge color={RISK_COLOR[r.risk_level]}>{t(`risk_${r.risk_level}`)}</Badge> },
{ key: 'updated_at', label: t('project_updated_at'), render: (r) => String(r.updated_at).slice(0, 16) },
+1
View File
@@ -229,6 +229,7 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
background: rgb(var(--danger) / .18); color: rgb(var(--danger));
font-size: .68rem; font-weight: 700; line-height: 1;
}
.count-badge { display: inline-flex; align-items: center; gap: 4px; color: rgb(var(--muted)); font-size: .8rem; white-space: nowrap; }
/* --- Cards / KPIs --- */
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 20px; }