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:
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => <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: '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>;
|
||||
@@ -81,11 +137,22 @@ export default function ProyectosPage() {
|
||||
<div>
|
||||
<div className="toolbar">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{showTrash && <div className="text-muted" style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('project_trash_empty_hint')}</div>}
|
||||
|
||||
<div className="toolbar">
|
||||
<SearchInput value={search} onChange={setSearch} />
|
||||
</div>
|
||||
@@ -96,7 +163,18 @@ export default function ProyectosPage() {
|
||||
searchValue={search}
|
||||
getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`}
|
||||
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}>
|
||||
|
||||
@@ -7,6 +7,9 @@ export const listProjects = (filters = {}) => {
|
||||
export const getProject = (id) => api.get(`/projects/${id}`);
|
||||
export const createProject = (data) => api.post('/projects', 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 removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`);
|
||||
export const getGantt = (id) => api.get(`/projects/${id}/gantt`);
|
||||
|
||||
Reference in New Issue
Block a user