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:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [2.10.7] - 2026-10-12
|
||||||
### Changed
|
### Changed
|
||||||
- Tarjetas de Notas: la fecha (abajo a la izquierda) queda al mismo nivel que los íconos de
|
- Tarjetas de Notas: la fecha (abajo a la izquierda) queda al mismo nivel que los íconos de
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.10.7",
|
"version": "2.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -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.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.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.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
|
FROM activities a
|
||||||
JOIN priorities pr ON pr.id = a.priority_id
|
JOIN priorities pr ON pr.id = a.priority_id
|
||||||
JOIN activity_statuses st ON st.id = a.status_id
|
JOIN activity_statuses st ON st.id = a.status_id
|
||||||
LEFT JOIN users u ON u.id = a.responsible_user_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) {
|
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]);
|
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) {
|
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]);
|
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 } = {}) {
|
export async function findById(id, { includeDeleted = false } = {}) {
|
||||||
const where = includeDeleted ? 'a.id = ?' : 'a.id = ? AND a.deleted_at IS NULL';
|
const where = includeDeleted ? 'a.id = ?' : 'a.id = ? AND a.deleted_at IS NULL';
|
||||||
const [rows] = await pool.query(`${LIST_SELECT} WHERE ${where}`, [id]);
|
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) {
|
export async function create(projectId, data) {
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ const LIST_SELECT = `
|
|||||||
p.responsible_department_id, sd.name AS responsible_department_name,
|
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.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,
|
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
|
FROM projects p
|
||||||
JOIN priorities pr ON pr.id = p.priority_id
|
JOIN priorities pr ON pr.id = p.priority_id
|
||||||
JOIN project_statuses ps ON ps.id = p.status_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
|
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 } = {}) {
|
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash } = {}) {
|
||||||
const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
|
const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -33,13 +52,13 @@ export async function findAll({ statusId, priorityId, ownerUserId, memberUserId,
|
|||||||
}
|
}
|
||||||
const where = `WHERE ${clauses.join(' AND ')}`;
|
const where = `WHERE ${clauses.join(' AND ')}`;
|
||||||
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
|
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 } = {}) {
|
export async function findById(id, { includeDeleted = false } = {}) {
|
||||||
const where = includeDeleted ? 'p.id = ?' : 'p.id = ? AND p.deleted_at IS NULL';
|
const where = includeDeleted ? 'p.id = ?' : 'p.id = ? AND p.deleted_at IS NULL';
|
||||||
const [rows] = await pool.query(`${LIST_SELECT} WHERE ${where}`, [id]);
|
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) {
|
export async function softDelete(id, userId) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.10.7",
|
"version": "2.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
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 { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge.jsx';
|
||||||
import { dueStatus } from '../../utils/dueStatus.js';
|
import { dueStatus } from '../../utils/dueStatus.js';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { CountBadge } from '../../components/CountBadge.jsx';
|
||||||
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
|
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
|
||||||
|
|
||||||
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
|
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
|
||||||
@@ -212,6 +213,9 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
</div>
|
</div>
|
||||||
) },
|
) },
|
||||||
{ key: 'progress_pct', label: t('activity_progress'), render: (r) => <div style={{ width: 100 }}><ProgressBar value={Number(r.progress_pct)} /></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> },
|
{ 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 { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
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 { Badge } from '../../components/Badge.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
import { SharedWithIcon } from '../../components/SharedWithIcon.jsx';
|
import { SharedWithIcon } from '../../components/SharedWithIcon.jsx';
|
||||||
|
import { CountBadge } from '../../components/CountBadge.jsx';
|
||||||
|
|
||||||
const CAN_DELETE_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
|
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: '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: '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: '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: '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: '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: '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) },
|
{ key: 'updated_at', label: t('project_updated_at'), render: (r) => String(r.updated_at).slice(0, 16) },
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
|||||||
background: rgb(var(--danger) / .18); color: rgb(var(--danger));
|
background: rgb(var(--danger) / .18); color: rgb(var(--danger));
|
||||||
font-size: .68rem; font-weight: 700; line-height: 1;
|
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 --- */
|
/* --- Cards / KPIs --- */
|
||||||
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 20px; }
|
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 20px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user