diff --git a/API.md b/API.md index 4452705..91f48a2 100644 --- a/API.md +++ b/API.md @@ -46,8 +46,13 @@ - `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id` ### 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` +- `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` ### Actividades (`/api/activities`) diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 8d20106..90962ed 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -67,6 +67,10 @@ `.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 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). - Producción siempre usa `192.168.222.32`. Desarrollo siempre usa `192.168.222.20`. No mezclar. diff --git a/CHANGELOG.md b/CHANGELOG.md index e90ab71..6527ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # 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 ### Added - Boton de revelar/ocultar clave (icono de ojo) en todos los campos de contraseña de la app: diff --git a/VERSION b/VERSION index 88c5fb8..bc80560 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.5.0 diff --git a/backend/database/migrations/003_project_soft_delete.sql b/backend/database/migrations/003_project_soft_delete.sql new file mode 100644 index 0000000..187d124 --- /dev/null +++ b/backend/database/migrations/003_project_soft_delete.sql @@ -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); diff --git a/backend/package.json b/backend/package.json index 692be5a..191f59b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.4.0", + "version": "1.5.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/dashboard/repository.js b/backend/src/modules/dashboard/repository.js index 9af92e3..04e1b3f 100644 --- a/backend/src/modules/dashboard/repository.js +++ b/backend/src/modules/dashboard/repository.js @@ -2,30 +2,37 @@ import { pool } from '../../config/db.js'; export async function summary() { 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( `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( `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)` ); 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( `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( `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( - `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 { @@ -43,7 +50,7 @@ export async function myPendingActivities(userId) { 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 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`, [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, 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 - WHERE a.due_date BETWEEN ? AND ? + WHERE a.due_date BETWEEN ? AND ? AND p.deleted_at IS NULL ORDER BY a.due_date`, [monthStart, monthEnd] ); @@ -65,7 +72,7 @@ export async function calendarActivities(monthStart, monthEnd) { export async function projectsByStatus() { const [rows] = await pool.query( `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` ); return rows; diff --git a/backend/src/modules/projects/controller.js b/backend/src/modules/projects/controller.js index 2a6f42e..eadb473 100644 --- a/backend/src/modules/projects/controller.js +++ b/backend/src/modules/projects/controller.js @@ -2,8 +2,8 @@ import { ok, asyncHandler } from '../../utils/http.js'; import * as service from './service.js'; export const getProjects = asyncHandler(async (req, res) => { - const { statusId, priorityId, ownerUserId } = req.query; - ok(res, await service.listProjects({ statusId, priorityId, ownerUserId })); + const { statusId, priorityId, ownerUserId, trash } = req.query; + ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true' }, req.currentUser)); }); 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)); }); +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) => { const members = await service.addMember(Number(req.params.id), Number(req.body.userId), req.body.roleInProject, req.currentUser); ok(res, members, 201); diff --git a/backend/src/modules/projects/repository.js b/backend/src/modules/projects/repository.js index bd0e8aa..c431170 100644 --- a/backend/src/modules/projects/repository.js +++ b/backend/src/modules/projects/repository.js @@ -8,7 +8,7 @@ const LIST_SELECT = ` p.requesting_department_id, rd.name AS requesting_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.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 JOIN priorities pr ON pr.id = p.priority_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 `; -export async function findAll({ statusId, priorityId, ownerUserId } = {}) { - const clauses = []; +export async function findAll({ statusId, priorityId, ownerUserId, trash } = {}) { + const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL']; const params = []; if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); } if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); } 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); return rows; } -export async function findById(id) { - const [rows] = await pool.query(`${LIST_SELECT} WHERE p.id = ?`, [id]); +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; } +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) { const [result] = await pool.query( `INSERT INTO projects diff --git a/backend/src/modules/projects/routes.js b/backend/src/modules/projects/routes.js index 5ca0906..232b176 100644 --- a/backend/src/modules/projects/routes.js +++ b/backend/src/modules/projects/routes.js @@ -1,6 +1,9 @@ import { Router } from 'express'; 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 { getActivitiesByProject, postActivity } from '../activities/controller.js'; @@ -11,6 +14,9 @@ router.get('/', getProjects); router.post('/', postProject); router.get('/:id', getProject); router.put('/:id', putProject); +router.delete('/:id', deleteProject); +router.post('/:id/restore', postProjectRestore); +router.delete('/:id/permanent', deleteProjectPermanent); router.get('/:id/gantt', getGantt); router.post('/:id/members', postMember); router.delete('/:id/members/:userId', deleteMember); diff --git a/backend/src/modules/projects/service.js b/backend/src/modules/projects/service.js index 7cd2c80..a60c25d 100644 --- a/backend/src/modules/projects/service.js +++ b/backend/src/modules/projects/service.js @@ -11,7 +11,13 @@ export async function canWriteProject(user, project) { 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) { const project = await repo.findById(id); @@ -48,6 +54,35 @@ export async function updateProject(id, payload, currentUser) { 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. export async function recalcProjectProgress(projectId) { const avg = await repo.averageActivityProgress(projectId); diff --git a/frontend/package.json b/frontend/package.json index 86cc16d..0fc8266 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.4.0", + "version": "1.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/ListView.jsx b/frontend/src/components/ListView.jsx index e0d40ea..5c00a30 100644 --- a/frontend/src/components/ListView.jsx +++ b/frontend/src/components/ListView.jsx @@ -5,9 +5,9 @@ import { Flyout, FlyoutItem } from './Flyout.jsx'; // 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). -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 [sort, setSort] = useState({ key: null, dir: 1 }); + const [sort, setSort] = useState(initialSort || { key: null, dir: 1 }); const filtered = useMemo(() => { if (!searchValue) return rows; diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 4072fe6..873b2fb 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -122,6 +122,16 @@ export const messages = { project_notes: 'Observaciones', project_members: 'Miembros', 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_medium: 'Medio', risk_high: 'Alto', @@ -307,6 +317,16 @@ export const messages = { project_notes: 'Notes', project_members: 'Members', 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_medium: 'Medium', risk_high: 'High', diff --git a/frontend/src/pages/Proyectos/ProyectosPage.jsx b/frontend/src/pages/Proyectos/ProyectosPage.jsx index 9db61eb..e6f1631 100644 --- a/frontend/src/pages/Proyectos/ProyectosPage.jsx +++ b/frontend/src/pages/Proyectos/ProyectosPage.jsx @@ -1,9 +1,10 @@ import { useState } from 'react'; 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 { useAuth } from '../../context/auth-context.jsx'; 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 { listDepartments } from '../../services/departments.js'; import { ListView } from '../../components/ListView.jsx'; @@ -15,6 +16,8 @@ import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx'; import { Badge } from '../../components/Badge.jsx'; import { DetailPopup } from '../../components/DetailPopup.jsx'; +const CAN_DELETE_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead']; + function flatten(nodes, acc = []) { for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); } return acc; @@ -28,11 +31,16 @@ const EMPTY_FORM = { }; export default function ProyectosPage() { - const { t, pushToast } = useUi(); + const { t, pushToast, openDialog } = useUi(); + const { user: currentUser } = useAuth(); const navigate = useNavigate(); 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: statuses } = useAsync(() => listCatalog('project-statuses'), []); 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 = [ { key: 'code', label: t('project_code') }, { key: 'name', label: t('common_name') }, @@ -73,6 +127,8 @@ export default function ProyectosPage() { { key: 'owner_name', label: t('project_owner') }, { key: 'progress_pct', label: t('project_progress'), render: (r) =>