feat: eliminar proyecto (logico), papelera para superadmin y orden por creacion

- DELETE /api/projects/🆔 eliminacion logica (deleted_at/deleted_by), mismo
  permiso que editar el proyecto. Popup de confirmacion antes de aplicarla.
- Papelera (GET /api/projects?trash=true, POST /:id/restore, DELETE
  /:id/permanent) restringida al superadmin; el borrado fisico exige que el
  proyecto ya este en la papelera y arrastra actividades/comentarios/adjuntos
  por ON DELETE CASCADE.
- Dashboard (KPIs, actividades pendientes/calendario, proyectos por estado)
  excluye proyectos eliminados logicamente.
- Columna "Creado" en el listado de proyectos, ordenado por defecto de mas
  nuevo a mas antiguo (ListView admite initialSort ademas del orden por clic
  en cabecera que ya tenia).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-09 09:26:35 -05:00
parent dd7caf51c7
commit 5a25ed066d
16 changed files with 247 additions and 33 deletions
+6 -1
View File
@@ -46,8 +46,13 @@
- `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id` - `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id`
### Proyectos (`/api/projects`) ### Proyectos (`/api/projects`)
- `GET /api/projects` (filtros: estado, responsable, prioridad) · `POST /api/projects` - `GET /api/projects` (filtros: estado, responsable, prioridad; `trash=true` = papelera, solo
superadmin) · `POST /api/projects`
- `GET /api/projects/:id` · `PUT /api/projects/:id` - `GET /api/projects/:id` · `PUT /api/projects/:id`
- `DELETE /api/projects/:id` — eliminación lógica (`deleted_at`/`deleted_by`), mismo permiso que
editar el proyecto. `POST /api/projects/:id/restore` y `DELETE /api/projects/:id/permanent`
(borrado físico, irreversible, arrastra actividades/comentarios/adjuntos) — ambos solo
superadmin; `permanent` exige que el proyecto ya esté en la papelera (400 si no).
- `POST /api/projects/:id/members` · `DELETE /api/projects/:id/members/:userId` - `POST /api/projects/:id/members` · `DELETE /api/projects/:id/members/:userId`
### Actividades (`/api/activities`) ### Actividades (`/api/activities`)
+4
View File
@@ -67,6 +67,10 @@
`.env`) — nunca en texto plano, nunca se devuelve por la API (solo `configured: true/false`). `.env`) — nunca en texto plano, nunca se devuelve por la API (solo `configured: true/false`).
- Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con - Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con
historial (los `pending_approval` rechazados sí se borran, ver arriba). historial (los `pending_approval` rechazados sí se borran, ver arriba).
- `projects.deleted_at`/`deleted_by`: eliminación lógica (quien puede editar el proyecto puede
"eliminarlo" — se oculta de listados/dashboard, sigue en BD). Borrado físico (`DELETE`, arrastra
actividades/comentarios/adjuntos por `ON DELETE CASCADE`) solo vía `permanentlyDeleteProject`,
solo superadmin, y solo sobre un proyecto que ya tiene `deleted_at` (dos pasos, nunca directo).
- Empresa asumida única (no hay tabla `companies` en el MVP). - Empresa asumida única (no hay tabla `companies` en el MVP).
- Producción siempre usa `192.168.222.32`. Desarrollo siempre usa `192.168.222.20`. No mezclar. - Producción siempre usa `192.168.222.32`. Desarrollo siempre usa `192.168.222.20`. No mezclar.
+20
View File
@@ -1,5 +1,25 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [1.5.0] - 2026-09-09
### Added
- Eliminar proyecto: eliminación **lógica** (`deleted_at`/`deleted_by`) disponible para quien puede
editar el proyecto (superadmin/admin/it_manager/project_lead dueño o miembro), con popup de
confirmación (`ContentDialog`) antes de aplicarla. El proyecto desaparece del listado y del
dashboard, pero sigue en la base de datos.
- Papelera de proyectos, visible solo para el **superadmin** (botón "Papelera" en Proyectos):
lista los proyectos eliminados lógicamente, con acciones "Restaurar" y "Eliminar
definitivamente" (esta última con su propia confirmación, más severa — advierte que también
borra actividades/comentarios/adjuntos). El borrado físico (`DELETE`) solo puede aplicarse sobre
un proyecto que ya pasó por la eliminación lógica (nunca en un solo paso).
- Columna "Creado" (fecha) en el listado de proyectos, ordenada por defecto de más nuevo a más
antiguo — un proyecto recién creado aparece primero sin tener que buscarlo. `ListView` ahora
soporta un orden inicial (`initialSort`) además del ordenamiento por clic en cabecera que ya tenía.
### Fixed
- Los KPIs del dashboard (proyectos activos/atrasados/por vencer, avance promedio, actividades
pendientes/atrasadas/de hoy, proyectos por estado) ahora excluyen proyectos eliminados
lógicamente — antes de este cambio no existía el concepto, así que no aplica como regresión,
pero se dejó explícito para que un futuro cambio no los vuelva a incluir por accidente.
## [1.4.0] - 2026-09-09 ## [1.4.0] - 2026-09-09
### Added ### Added
- Boton de revelar/ocultar clave (icono de ojo) en todos los campos de contraseña de la app: - Boton de revelar/ocultar clave (icono de ojo) en todos los campos de contraseña de la app:
+1 -1
View File
@@ -1 +1 @@
1.4.0 1.5.0
@@ -0,0 +1,8 @@
-- Eliminacion logica de proyectos: deleted_at/deleted_by. Idempotente.
SET NAMES utf8mb4;
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL,
ADD COLUMN IF NOT EXISTS deleted_by INT NULL;
CREATE INDEX IF NOT EXISTS idx_projects_deleted_at ON projects(deleted_at);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "1.4.0", "version": "1.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+17 -10
View File
@@ -2,30 +2,37 @@ import { pool } from '../../config/db.js';
export async function summary() { export async function summary() {
const [[activeProjects]] = await pool.query( const [[activeProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id WHERE ps.is_closed = 0` `SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL`
); );
const [[overdueProjects]] = await pool.query( const [[overdueProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id `SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE ps.is_closed = 0 AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE()` WHERE ps.is_closed = 0 AND p.deleted_at IS NULL
AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE()`
); );
const [[dueSoonProjects]] = await pool.query( const [[dueSoonProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id `SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE ps.is_closed = 0 AND p.estimated_end_date IS NOT NULL WHERE ps.is_closed = 0 AND p.deleted_at IS NULL AND p.estimated_end_date IS NOT NULL
AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)` AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)`
); );
const [[pendingActivities]] = await pool.query( const [[pendingActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id WHERE st.is_closed = 0` `SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
JOIN projects p ON p.id = a.project_id
WHERE st.is_closed = 0 AND p.deleted_at IS NULL`
); );
const [[overdueActivities]] = await pool.query( const [[overdueActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id `SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
WHERE st.is_closed = 0 AND a.due_date IS NOT NULL AND a.due_date < CURDATE()` JOIN projects p ON p.id = a.project_id
WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.due_date IS NOT NULL AND a.due_date < CURDATE()`
); );
const [[todayActivities]] = await pool.query( const [[todayActivities]] = await pool.query(
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id `SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
WHERE st.is_closed = 0 AND a.due_date = CURDATE()` JOIN projects p ON p.id = a.project_id
WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.due_date = CURDATE()`
); );
const [[avgProgress]] = await pool.query( const [[avgProgress]] = await pool.query(
`SELECT AVG(progress_pct) AS avg_pct FROM projects p JOIN project_statuses ps ON ps.id = p.status_id WHERE ps.is_closed = 0` `SELECT AVG(progress_pct) AS avg_pct FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL`
); );
return { return {
@@ -43,7 +50,7 @@ export async function myPendingActivities(userId) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT a.id, a.code, a.name, a.due_date, a.progress_pct, p.id AS project_id, p.name AS project_name `SELECT a.id, a.code, a.name, a.due_date, a.progress_pct, p.id AS project_id, p.name AS project_name
FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id
WHERE st.is_closed = 0 AND a.responsible_user_id = ? WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.responsible_user_id = ?
ORDER BY (a.due_date IS NULL), a.due_date LIMIT 20`, ORDER BY (a.due_date IS NULL), a.due_date LIMIT 20`,
[userId] [userId]
); );
@@ -55,7 +62,7 @@ export async function calendarActivities(monthStart, monthEnd) {
`SELECT a.id, a.code, a.name, a.due_date, a.is_milestone, a.progress_pct, `SELECT a.id, a.code, a.name, a.due_date, a.is_milestone, a.progress_pct,
st.is_closed AS status_is_closed, p.id AS project_id, p.name AS project_name st.is_closed AS status_is_closed, p.id AS project_id, p.name AS project_name
FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id FROM activities a JOIN activity_statuses st ON st.id = a.status_id JOIN projects p ON p.id = a.project_id
WHERE a.due_date BETWEEN ? AND ? WHERE a.due_date BETWEEN ? AND ? AND p.deleted_at IS NULL
ORDER BY a.due_date`, ORDER BY a.due_date`,
[monthStart, monthEnd] [monthStart, monthEnd]
); );
@@ -65,7 +72,7 @@ export async function calendarActivities(monthStart, monthEnd) {
export async function projectsByStatus() { export async function projectsByStatus() {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT ps.name, ps.color, COUNT(p.id) AS total `SELECT ps.name, ps.color, COUNT(p.id) AS total
FROM project_statuses ps LEFT JOIN projects p ON p.status_id = ps.id FROM project_statuses ps LEFT JOIN projects p ON p.status_id = ps.id AND p.deleted_at IS NULL
GROUP BY ps.id ORDER BY ps.sort_order` GROUP BY ps.id ORDER BY ps.sort_order`
); );
return rows; return rows;
+17 -2
View File
@@ -2,8 +2,8 @@ import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js'; import * as service from './service.js';
export const getProjects = asyncHandler(async (req, res) => { export const getProjects = asyncHandler(async (req, res) => {
const { statusId, priorityId, ownerUserId } = req.query; const { statusId, priorityId, ownerUserId, trash } = req.query;
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId })); ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true' }, req.currentUser));
}); });
export const getProject = asyncHandler(async (req, res) => { export const getProject = asyncHandler(async (req, res) => {
@@ -18,6 +18,21 @@ export const putProject = asyncHandler(async (req, res) => {
ok(res, await service.updateProject(Number(req.params.id), req.body, req.currentUser)); ok(res, await service.updateProject(Number(req.params.id), req.body, req.currentUser));
}); });
export const deleteProject = asyncHandler(async (req, res) => {
await service.deleteProject(Number(req.params.id), req.currentUser);
ok(res, { deleted: true });
});
export const postProjectRestore = asyncHandler(async (req, res) => {
await service.restoreProject(Number(req.params.id), req.currentUser);
ok(res, { restored: true });
});
export const deleteProjectPermanent = asyncHandler(async (req, res) => {
await service.permanentlyDeleteProject(Number(req.params.id), req.currentUser);
ok(res, { deleted: true });
});
export const postMember = asyncHandler(async (req, res) => { export const postMember = asyncHandler(async (req, res) => {
const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser); const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser);
ok(res, members, 201); ok(res, members, 201);
+19 -6
View File
@@ -8,7 +8,7 @@ const LIST_SELECT = `
p.requesting_department_id, rd.name AS requesting_department_name, p.requesting_department_id, rd.name AS requesting_department_name,
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.risk_level, p.notes, p.created_by, p.created_at, p.updated_at, p.deleted_at, p.deleted_by
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
@@ -18,22 +18,35 @@ 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
`; `;
export async function findAll({ statusId, priorityId, ownerUserId } = {}) { export async function findAll({ statusId, priorityId, ownerUserId, trash } = {}) {
const clauses = []; const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
const params = []; const params = [];
if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); } if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); }
if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); } if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); }
if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); } if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); }
const where = clauses.length ? `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;
} }
export async function findById(id) { export async function findById(id, { includeDeleted = false } = {}) {
const [rows] = await pool.query(`${LIST_SELECT} WHERE p.id = ?`, [id]); 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] || null;
} }
export async function softDelete(id, userId) {
await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL', [userId, id]);
}
export async function restore(id) {
await pool.query('UPDATE projects SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
}
export async function hardDelete(id) {
await pool.query('DELETE FROM projects WHERE id = ?', [id]);
}
export async function create(data) { export async function create(data) {
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO projects `INSERT INTO projects
+7 -1
View File
@@ -1,6 +1,9 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js'; import { requireAuth } from '../../middleware/auth.js';
import { getProjects, getProject, postProject, putProject, postMember, deleteMember } from './controller.js'; import {
getProjects, getProject, postProject, putProject, deleteProject, postProjectRestore,
deleteProjectPermanent, postMember, deleteMember,
} from './controller.js';
import { getGantt } from '../dashboard/controller.js'; import { getGantt } from '../dashboard/controller.js';
import { getActivitiesByProject, postActivity } from '../activities/controller.js'; import { getActivitiesByProject, postActivity } from '../activities/controller.js';
@@ -11,6 +14,9 @@ router.get('/', getProjects);
router.post('/', postProject); router.post('/', postProject);
router.get('/:id', getProject); router.get('/:id', getProject);
router.put('/:id', putProject); router.put('/:id', putProject);
router.delete('/:id', deleteProject);
router.post('/:id/restore', postProjectRestore);
router.delete('/:id/permanent', deleteProjectPermanent);
router.get('/:id/gantt', getGantt); router.get('/:id/gantt', getGantt);
router.post('/:id/members', postMember); router.post('/:id/members', postMember);
router.delete('/:id/members/:userId', deleteMember); router.delete('/:id/members/:userId', deleteMember);
+36 -1
View File
@@ -11,7 +11,13 @@ export async function canWriteProject(user, project) {
return repo.isMember(project.id, user.id); return repo.isMember(project.id, user.id);
} }
export const listProjects = (filters) => repo.findAll(filters); // Solo el superadmin puede ver la papelera (proyectos eliminados logicamente).
export async function listProjects(filters, currentUser) {
if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) {
throw new HttpError(403, 'Solo el superadministrador puede ver la papelera');
}
return repo.findAll(filters);
}
export async function getProject(id) { export async function getProject(id) {
const project = await repo.findById(id); const project = await repo.findById(id);
@@ -48,6 +54,35 @@ export async function updateProject(id, payload, currentUser) {
return updated; return updated;
} }
// Eliminacion logica: cualquiera que pueda editar el proyecto puede "eliminarlo" (deja de verse
// en el listado, pero sigue en la BD). Solo el superadmin puede restaurarlo o borrarlo definitivo.
export async function deleteProject(id, currentUser) {
const project = await repo.findById(id);
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
await repo.softDelete(id, currentUser.id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'project', entityId: id });
}
export async function restoreProject(id, currentUser) {
if (currentUser.roleCode !== ROLES.SUPERADMIN) throw new HttpError(403, 'Solo el superadministrador puede restaurar proyectos');
const project = await repo.findById(id, { includeDeleted: true });
if (!project || !project.deleted_at) throw new HttpError(404, 'Proyecto no encontrado en la papelera');
await repo.restore(id);
await logAudit({ userId: currentUser.id, action: 'restore', entityType: 'project', entityId: id });
}
// Borrado fisico e irreversible (arrastra actividades/comentarios/adjuntos por ON DELETE CASCADE).
// Solo el superadmin, y solo sobre un proyecto que ya paso por eliminacion logica primero.
export async function permanentlyDeleteProject(id, currentUser) {
if (currentUser.roleCode !== ROLES.SUPERADMIN) throw new HttpError(403, 'Solo el superadministrador puede eliminar proyectos definitivamente');
const project = await repo.findById(id, { includeDeleted: true });
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
if (!project.deleted_at) throw new HttpError(400, 'El proyecto debe eliminarse primero antes de poder borrarlo definitivamente');
await repo.hardDelete(id);
await logAudit({ userId: currentUser.id, action: 'permanent_delete', entityType: 'project', entityId: id });
}
// Recalcula el avance del proyecto como promedio del avance de sus actividades. // Recalcula el avance del proyecto como promedio del avance de sus actividades.
export async function recalcProjectProgress(projectId) { export async function recalcProjectProgress(projectId) {
const avg = await repo.averageActivityProgress(projectId); const avg = await repo.averageActivityProgress(projectId);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "1.4.0", "version": "1.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2 -2
View File
@@ -5,9 +5,9 @@ import { Flyout, FlyoutItem } from './Flyout.jsx';
// ListView/DataGrid: columnas ordenables, buscador ligero sobre el haystack completo, clic en // ListView/DataGrid: columnas ordenables, buscador ligero sobre el haystack completo, clic en
// fila abre detalle, boton "..." abre el menu de acciones (equivalente al clic derecho). // fila abre detalle, boton "..." abre el menu de acciones (equivalente al clic derecho).
export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey }) { export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort }) {
const { t } = useUi(); const { t } = useUi();
const [sort, setSort] = useState({ key: null, dir: 1 }); const [sort, setSort] = useState(initialSort || { key: null, dir: 1 });
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!searchValue) return rows; if (!searchValue) return rows;
+20
View File
@@ -122,6 +122,16 @@ export const messages = {
project_notes: 'Observaciones', project_notes: 'Observaciones',
project_members: 'Miembros', project_members: 'Miembros',
project_add_member: 'Agregar miembro', project_add_member: 'Agregar miembro',
project_created_at: 'Creado',
project_deleted_at: 'Eliminado',
project_delete_confirm_msg: '¿Seguro que deseas eliminar este proyecto? Se movera a la papelera y dejara de verse en el listado; un superadministrador puede restaurarlo o borrarlo definitivamente.',
project_trash: 'Papelera',
project_trash_title: 'Papelera de proyectos',
project_trash_back: 'Volver a proyectos',
project_trash_empty_hint: 'Los proyectos eliminados aparecen aqui hasta que se restauran o se borran definitivamente.',
project_restore: 'Restaurar',
project_delete_permanent: 'Eliminar definitivamente',
project_delete_permanent_confirm_msg: 'Esta accion NO se puede deshacer: se borra el proyecto y todas sus actividades, comentarios y archivos adjuntos de forma permanente.',
risk_low: 'Bajo', risk_low: 'Bajo',
risk_medium: 'Medio', risk_medium: 'Medio',
risk_high: 'Alto', risk_high: 'Alto',
@@ -307,6 +317,16 @@ export const messages = {
project_notes: 'Notes', project_notes: 'Notes',
project_members: 'Members', project_members: 'Members',
project_add_member: 'Add member', project_add_member: 'Add member',
project_created_at: 'Created',
project_deleted_at: 'Deleted',
project_delete_confirm_msg: 'Are you sure you want to delete this project? It will move to the trash and disappear from the list; a superadmin can restore it or delete it permanently.',
project_trash: 'Trash',
project_trash_title: 'Project trash',
project_trash_back: 'Back to projects',
project_trash_empty_hint: 'Deleted projects show up here until they are restored or permanently deleted.',
project_restore: 'Restore',
project_delete_permanent: 'Delete permanently',
project_delete_permanent_confirm_msg: 'This CANNOT be undone: the project and all its activities, comments and attachments will be permanently deleted.',
risk_low: 'Low', risk_low: 'Low',
risk_medium: 'Medium', risk_medium: 'Medium',
risk_high: 'High', risk_high: 'High',
+85 -7
View File
@@ -1,9 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Plus, Save } from 'lucide-react'; import { Plus, Save, Trash2, RotateCcw, ArrowLeft } 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 { useAsync, useAction } from '../../hooks/useAsync.js'; import { useAsync, useAction } from '../../hooks/useAsync.js';
import { listProjects, createProject } from '../../services/projects.js'; import { listProjects, createProject, deleteProject, restoreProject, permanentlyDeleteProject } from '../../services/projects.js';
import { listCatalog } from '../../services/catalogs.js'; import { listCatalog } from '../../services/catalogs.js';
import { listDepartments } from '../../services/departments.js'; import { listDepartments } from '../../services/departments.js';
import { ListView } from '../../components/ListView.jsx'; import { ListView } from '../../components/ListView.jsx';
@@ -15,6 +16,8 @@ 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';
const CAN_DELETE_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
function flatten(nodes, acc = []) { function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); } for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
return acc; return acc;
@@ -28,11 +31,16 @@ const EMPTY_FORM = {
}; };
export default function ProyectosPage() { export default function ProyectosPage() {
const { t, pushToast } = useUi(); const { t, pushToast, openDialog } = useUi();
const { user: currentUser } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const runAction = useAction(); const runAction = useAction();
const { data: projects, reload } = useAsync(listProjects, []); const [showTrash, setShowTrash] = useState(false);
const isSuperadmin = currentUser?.roleCode === 'superadmin';
const canDelete = CAN_DELETE_ROLES.includes(currentUser?.roleCode);
const { data: projects, reload } = useAsync(() => listProjects(showTrash ? { trash: true } : {}), [showTrash]);
const { data: priorities } = useAsync(() => listCatalog('priorities'), []); const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []); const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
const { data: types } = useAsync(() => listCatalog('project-types'), []); const { data: types } = useAsync(() => listCatalog('project-types'), []);
@@ -65,6 +73,52 @@ export default function ProyectosPage() {
}); });
} }
function confirmDelete(project) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('project_delete_confirm_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteProject(project.id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
function confirmPurge(project) {
openDialog({
tone: 'error',
title: t('project_delete_permanent'),
message: t('project_delete_permanent_confirm_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('project_delete_permanent'), variant: 'danger',
onClick: () => runAction(async () => {
await permanentlyDeleteProject(project.id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
function restore(project) {
runAction(async () => {
await restoreProject(project.id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
});
}
const columns = [ const columns = [
{ key: 'code', label: t('project_code') }, { key: 'code', label: t('project_code') },
{ key: 'name', label: t('common_name') }, { key: 'name', label: t('common_name') },
@@ -73,6 +127,8 @@ export default function ProyectosPage() {
{ key: 'owner_name', label: t('project_owner') }, { key: 'owner_name', label: t('project_owner') },
{ key: 'progress_pct', label: t('project_progress'), render: (r) => <div style={{ width: 100 }}><ProgressBar value={Number(r.progress_pct)} /></div> }, { key: 'progress_pct', label: t('project_progress'), render: (r) => <div style={{ width: 100 }}><ProgressBar value={Number(r.progress_pct)} /></div> },
{ 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: 'created_at', label: t('project_created_at'), render: (r) => String(r.created_at).slice(0, 10) },
...(showTrash ? [{ key: 'deleted_at', label: t('project_deleted_at'), render: (r) => String(r.deleted_at).slice(0, 10) }] : []),
]; ];
if (!projects) return <div className="text-muted">{t('common_loading')}</div>; if (!projects) return <div className="text-muted">{t('common_loading')}</div>;
@@ -81,11 +137,22 @@ export default function ProyectosPage() {
<div> <div>
<div className="toolbar"> <div className="toolbar">
<div className="toolbar-left"> <div className="toolbar-left">
<h2 className="page-title mt-0">{t('projects_title')}</h2> <h2 className="page-title mt-0">{showTrash ? t('project_trash_title') : t('projects_title')}</h2>
</div> </div>
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}</Button> {showTrash ? (
<Button variant="standard" icon={ArrowLeft} onClick={() => setShowTrash(false)}>{t('project_trash_back')}</Button>
) : (
<div style={{ display: 'flex', gap: 8 }}>
{isSuperadmin && (
<Button variant="standard" icon={Trash2} onClick={() => setShowTrash(true)}>{t('project_trash')}</Button>
)}
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}</Button>
</div>
)}
</div> </div>
{showTrash && <div className="text-muted" style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('project_trash_empty_hint')}</div>}
<div className="toolbar"> <div className="toolbar">
<SearchInput value={search} onChange={setSearch} /> <SearchInput value={search} onChange={setSearch} />
</div> </div>
@@ -96,7 +163,18 @@ export default function ProyectosPage() {
searchValue={search} searchValue={search}
getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`} getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`}
getRowKey={(p) => p.id} getRowKey={(p) => p.id}
onRowClick={(p) => navigate(`/proyectos/${p.id}`)} onRowClick={showTrash ? undefined : (p) => navigate(`/proyectos/${p.id}`)}
initialSort={{ key: 'created_at', dir: -1 }}
rowActions={
showTrash
? (p) => [
{ label: t('project_restore'), icon: RotateCcw, onClick: () => restore(p) },
{ label: t('project_delete_permanent'), icon: Trash2, danger: true, onClick: () => confirmPurge(p) },
]
: canDelete
? (p) => [{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(p) }]
: undefined
}
/> />
<DetailPopup open={creating} title={t('project_new')} onClose={() => setCreating(false)} width={720} height={720}> <DetailPopup open={creating} title={t('project_new')} onClose={() => setCreating(false)} width={720} height={720}>
+3
View File
@@ -7,6 +7,9 @@ export const listProjects = (filters = {}) => {
export const getProject = (id) => api.get(`/projects/${id}`); export const getProject = (id) => api.get(`/projects/${id}`);
export const createProject = (data) => api.post('/projects', data); export const createProject = (data) => api.post('/projects', data);
export const updateProject = (id, data) => api.put(`/projects/${id}`, data); export const updateProject = (id, data) => api.put(`/projects/${id}`, data);
export const deleteProject = (id) => api.delete(`/projects/${id}`);
export const restoreProject = (id) => api.post(`/projects/${id}/restore`);
export const permanentlyDeleteProject = (id) => api.delete(`/projects/${id}/permanent`);
export const addProjectMember = (id, userId, roleInProject) => api.post(`/projects/${id}/members`, { userId, roleInProject }); export const addProjectMember = (id, userId, roleInProject) => api.post(`/projects/${id}/members`, { userId, roleInProject });
export const removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`); export const removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`);
export const getGantt = (id) => api.get(`/projects/${id}/gantt`); export const getGantt = (id) => api.get(`/projects/${id}/gantt`);