feat: administradores por proyecto y Configuracion solo para superadmin
- Un proyecto puede tener uno o mas administradores (role_in_project= 'admin' en project_members), independiente del rol global del usuario. Nuevo PUT /api/projects/:id/members/:userId/role; canWriteProject lo reconoce ademas del comportamiento previo (admin/superadmin/it_manager global, o project_lead miembro). UI: casilla al agregar miembro + boton de escudo para promover/degradar (nunca toca al Dueño). - Menu "Configuracion" (apariencia, catalogos, SMTP) visible y accesible solo para el superadmin: oculto en el nav y con guarda en la ruta /configuracion para quien la teclee directo. Escritura de catalogos restringida de [superadmin, admin] a solo superadmin para que el backend sea consistente con la UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,11 @@
|
||||
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` (`roleInProject` opcional: `'admin'` = administrador del
|
||||
proyecto) · `PUT /api/projects/:id/members/:userId/role` (`{ admin: true|false }`, otorga o
|
||||
quita el rol de administrador **de ese proyecto** — un proyecto puede tener uno o más
|
||||
administradores, sin importar el rol global del usuario; nunca toca al `owner`) ·
|
||||
`DELETE /api/projects/:id/members/:userId`
|
||||
|
||||
### Actividades (`/api/activities`)
|
||||
- `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities`
|
||||
@@ -78,7 +82,7 @@
|
||||
- `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite
|
||||
- `GET /api/projects/:id/gantt` — actividades con fechas/avance para el Gantt
|
||||
|
||||
### Catálogos (`/api/catalogs`) — lectura general, escritura solo Administrador
|
||||
### Catálogos (`/api/catalogs`) — lectura general, escritura solo Superadministrador
|
||||
- `GET/POST/PUT/DELETE /api/catalogs/project-statuses`
|
||||
- `GET/POST/PUT/DELETE /api/catalogs/activity-statuses`
|
||||
- `GET/POST/PUT/DELETE /api/catalogs/priorities`
|
||||
|
||||
@@ -71,6 +71,11 @@
|
||||
"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).
|
||||
- `project_members.role_in_project`: `'owner'` (dueño, se asigna solo al crear el proyecto, nunca
|
||||
se toca desde el endpoint de rol), `'admin'` (administrador **de ese proyecto puntual** — un
|
||||
proyecto puede tener varios, otorgado por quien ya puede escribir el proyecto, sin importar el
|
||||
rol global de a quién se lo otorgan), `NULL`/otro = miembro sin permisos de escritura (salvo que
|
||||
además sea `project_lead` global, comportamiento previo sin cambios).
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Changelog - project.linkedcorp.net
|
||||
|
||||
## [1.6.0] - 2026-09-09
|
||||
### Added
|
||||
- Un proyecto puede tener **uno o más administradores del proyecto** (`role_in_project = 'admin'`
|
||||
en `project_members`), sin importar el rol global del usuario — un `collaborator` puede ser
|
||||
administrador de un proyecto puntual. Se otorga/quita desde Miembros: casilla "Agregar como
|
||||
administrador del proyecto" al agregar, y un botón de escudo para promover/degradar a un
|
||||
miembro existente (protegido: nunca toca al `Dueño`). Nuevo `PUT
|
||||
/api/projects/:id/members/:userId/role`. `canWriteProject` ahora reconoce este rol por-proyecto
|
||||
además del comportamiento previo (admin/superadmin/it_manager global, o project_lead miembro).
|
||||
### Changed
|
||||
- El menú "Configuración" (apariencia, catálogos, SMTP) ahora es **visible y accesible solo para
|
||||
el superadmin** — antes cualquier usuario logueado lo veía y podía entrar (el tema/idioma
|
||||
personal sigue disponible para todos desde los íconos de la barra superior, eso no cambió). Se
|
||||
guarda tanto en el menú lateral como en la ruta directa `/configuracion`.
|
||||
- Escritura de catálogos (`/api/catalogs/*`, usado por la pantalla de Configuración) restringida
|
||||
de `[superadmin, admin]` a solo `superadmin`, para que el backend sea consistente con que
|
||||
`admin` ya no ve ni puede entrar a esa pantalla — la lectura sigue abierta a cualquier usuario
|
||||
(los combos de prioridad/estado en el resto de la app la necesitan).
|
||||
|
||||
## [1.5.0] - 2026-09-09
|
||||
### Added
|
||||
- Eliminar proyecto: eliminación **lógica** (`deleted_at`/`deleted_by`) disponible para quien puede
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -54,7 +54,7 @@ export const ROLES = {
|
||||
VIEWER: 'viewer',
|
||||
};
|
||||
|
||||
export const CAN_MANAGE_CATALOGS = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
||||
export const CAN_MANAGE_CATALOGS = [ROLES.SUPERADMIN];
|
||||
export const CAN_MANAGE_USERS = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
||||
export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN];
|
||||
export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
|
||||
|
||||
@@ -38,6 +38,11 @@ export const postMember = asyncHandler(async (req, res) => {
|
||||
ok(res, members, 201);
|
||||
});
|
||||
|
||||
export const putMemberRole = asyncHandler(async (req, res) => {
|
||||
const members = await service.setMemberAdmin(Number(req.params.id), Number(req.params.userId), !!req.body.admin, req.currentUser);
|
||||
ok(res, members);
|
||||
});
|
||||
|
||||
export const deleteMember = asyncHandler(async (req, res) => {
|
||||
const members = await service.removeMember(Number(req.params.id), Number(req.params.userId), req.currentUser);
|
||||
ok(res, members);
|
||||
|
||||
@@ -111,6 +111,15 @@ export async function isMember(projectId, userId) {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// null = no es miembro; 'member' = miembro sin rol especifico; 'admin'/'owner' = con ese rol.
|
||||
export async function getMembership(projectId, userId) {
|
||||
const [rows] = await pool.query('SELECT role_in_project FROM project_members WHERE project_id = ? AND user_id = ?', [
|
||||
projectId, userId,
|
||||
]);
|
||||
if (!rows.length) return null;
|
||||
return rows[0].role_in_project || 'member';
|
||||
}
|
||||
|
||||
export async function addMember(projectId, userId, roleInProject) {
|
||||
await pool.query(
|
||||
'INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)',
|
||||
@@ -119,6 +128,16 @@ export async function addMember(projectId, userId, roleInProject) {
|
||||
return listMembers(projectId);
|
||||
}
|
||||
|
||||
// Nunca toca una fila con role_in_project = 'owner' (protege al dueño de perder ese rol por aqui).
|
||||
export async function updateMemberRole(projectId, userId, roleInProject) {
|
||||
await pool.query(
|
||||
`UPDATE project_members SET role_in_project = ?
|
||||
WHERE project_id = ? AND user_id = ? AND (role_in_project IS NULL OR role_in_project <> 'owner')`,
|
||||
[roleInProject, projectId, userId]
|
||||
);
|
||||
return listMembers(projectId);
|
||||
}
|
||||
|
||||
export async function removeMember(projectId, userId) {
|
||||
await pool.query('DELETE FROM project_members WHERE project_id = ? AND user_id = ?', [projectId, userId]);
|
||||
return listMembers(projectId);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import {
|
||||
getProjects, getProject, postProject, putProject, deleteProject, postProjectRestore,
|
||||
deleteProjectPermanent, postMember, deleteMember,
|
||||
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
|
||||
} from './controller.js';
|
||||
import { getGantt } from '../dashboard/controller.js';
|
||||
import { getActivitiesByProject, postActivity } from '../activities/controller.js';
|
||||
@@ -19,6 +19,7 @@ router.post('/:id/restore', postProjectRestore);
|
||||
router.delete('/:id/permanent', deleteProjectPermanent);
|
||||
router.get('/:id/gantt', getGantt);
|
||||
router.post('/:id/members', postMember);
|
||||
router.put('/:id/members/:userId/role', putMemberRole);
|
||||
router.delete('/:id/members/:userId', deleteMember);
|
||||
router.get('/:projectId/activities', getActivitiesByProject);
|
||||
router.post('/:projectId/activities', postActivity);
|
||||
|
||||
@@ -3,12 +3,17 @@ import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { ROLES } from '../../middleware/auth.js';
|
||||
|
||||
// Alcance de escritura: admin/it_manager siempre; project_lead solo si es dueño o miembro.
|
||||
// Alcance de escritura: admin/superadmin/it_manager siempre; un proyecto puede tener uno o mas
|
||||
// "administradores del proyecto" (role_in_project = 'owner'/'admin' en project_members) con acceso
|
||||
// total sin importar su rol global — un collaborator puede ser admin de UN proyecto puntual;
|
||||
// ademas, por compatibilidad, cualquier project_lead que sea miembro (con o sin ese tag) tambien
|
||||
// puede escribir, igual que antes de introducir el rol de administrador por proyecto.
|
||||
export async function canWriteProject(user, project) {
|
||||
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true;
|
||||
if (user.roleCode !== ROLES.PROJECT_LEAD) return false;
|
||||
if (project.owner_user_id === user.id) return true;
|
||||
return repo.isMember(project.id, user.id);
|
||||
const membership = await repo.getMembership(project.id, user.id);
|
||||
if (membership === 'owner' || membership === 'admin') return true;
|
||||
if (user.roleCode === ROLES.PROJECT_LEAD && membership !== null) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Solo el superadmin puede ver la papelera (proyectos eliminados logicamente).
|
||||
@@ -98,6 +103,18 @@ export async function addMember(projectId, userId, roleInProject, currentUser) {
|
||||
return members;
|
||||
}
|
||||
|
||||
export async function setMemberAdmin(projectId, userId, isAdmin, currentUser) {
|
||||
const project = await repo.findById(projectId);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
||||
const members = await repo.updateMemberRole(projectId, userId, isAdmin ? 'admin' : null);
|
||||
await logAudit({
|
||||
userId: currentUser.id, action: isAdmin ? 'grant_project_admin' : 'revoke_project_admin',
|
||||
entityType: 'project', entityId: projectId, detail: { userId },
|
||||
});
|
||||
return members;
|
||||
}
|
||||
|
||||
export async function removeMember(projectId, userId, currentUser) {
|
||||
const project = await repo.findById(projectId);
|
||||
if (!project) throw new HttpError(404, 'Proyecto no encontrado');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { LayoutDashboard, FolderKanban, Building2, Settings } from 'lucide-react';
|
||||
import { useUi } from '../context/ui-context.jsx';
|
||||
import { useAuth } from '../context/auth-context.jsx';
|
||||
import logoDark from '../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../assets/logoLinkedCorpTxtBlack.png';
|
||||
import logoIcon from '../assets/logoLinked1.png';
|
||||
@@ -9,12 +10,14 @@ const ITEMS = [
|
||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
|
||||
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
|
||||
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
|
||||
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config' },
|
||||
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
|
||||
];
|
||||
|
||||
export function NavigationView({ mode }) {
|
||||
const { t, theme } = useUi();
|
||||
const { user } = useAuth();
|
||||
const compact = mode === 'compact';
|
||||
const items = ITEMS.filter((item) => !item.superadminOnly || user?.roleCode === 'superadmin');
|
||||
return (
|
||||
<nav className={`nav-view ${mode}`}>
|
||||
<div className="nav-header">
|
||||
@@ -25,7 +28,7 @@ export function NavigationView({ mode }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="nav-items">
|
||||
{ITEMS.map((item) => (
|
||||
{items.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||
<item.icon />
|
||||
{mode !== 'compact' && <span>{t(item.labelKey)}</span>}
|
||||
|
||||
@@ -122,6 +122,11 @@ export const messages = {
|
||||
project_notes: 'Observaciones',
|
||||
project_members: 'Miembros',
|
||||
project_add_member: 'Agregar miembro',
|
||||
project_add_member_as_admin: 'Agregar como administrador del proyecto',
|
||||
project_role_owner: 'Dueño',
|
||||
project_role_admin: 'Administrador',
|
||||
project_make_admin: 'Hacer administrador',
|
||||
project_remove_admin: 'Quitar administrador',
|
||||
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.',
|
||||
@@ -317,6 +322,11 @@ export const messages = {
|
||||
project_notes: 'Notes',
|
||||
project_members: 'Members',
|
||||
project_add_member: 'Add member',
|
||||
project_add_member_as_admin: 'Add as project administrator',
|
||||
project_role_owner: 'Owner',
|
||||
project_role_admin: 'Administrator',
|
||||
project_make_admin: 'Make administrator',
|
||||
project_remove_admin: 'Remove administrator',
|
||||
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.',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
@@ -11,6 +12,10 @@ export default function ConfiguracionPage() {
|
||||
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Regla dura: Configuracion es solo del superadmin (catalogos, SMTP...). El nav ya la oculta
|
||||
// para los demas roles; esta guarda de ruta evita entrar tecleando /configuracion directo.
|
||||
if (user?.roleCode !== 'superadmin') return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('config_title')}</h2>
|
||||
@@ -88,11 +93,9 @@ export default function ConfiguracionPage() {
|
||||
/>
|
||||
</Expander>
|
||||
|
||||
{user?.roleCode === 'superadmin' && (
|
||||
<Expander icon={Mail} title={t('config_smtp')}>
|
||||
<SmtpSettingsPanel />
|
||||
</Expander>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { UserPlus, Trash2 } from 'lucide-react';
|
||||
import { UserPlus, Trash2, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { addProjectMember, removeProjectMember } from '../../services/projects.js';
|
||||
import { addProjectMember, setProjectMemberAdmin, removeProjectMember } from '../../services/projects.js';
|
||||
import { listUsers } from '../../services/users.js';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
|
||||
const ROLE_BADGE = { owner: { color: 'accent', key: 'project_role_owner' }, admin: { color: 'success', key: 'project_role_admin' } };
|
||||
|
||||
export default function ProjectMembersTab({ project, onChanged }) {
|
||||
const { t, openDialog } = useUi();
|
||||
const runAction = useAction();
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
|
||||
const [addingMember, setAddingMember] = useState('');
|
||||
const [addAsAdmin, setAddAsAdmin] = useState(false);
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||
|
||||
const userOptions = (users || []).filter((u) => !project.members.some((m) => m.user_id === u.id)).map((u) => ({ value: u.id, label: u.name }));
|
||||
@@ -22,13 +26,21 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
||||
async function addMember() {
|
||||
if (!addingMember) return;
|
||||
await runAction(async () => {
|
||||
await addProjectMember(project.id, Number(addingMember));
|
||||
await addProjectMember(project.id, Number(addingMember), addAsAdmin ? 'admin' : null);
|
||||
setAddingMember('');
|
||||
setAddAsAdmin(false);
|
||||
setAddMemberOpen(false);
|
||||
onChanged();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAdmin(member) {
|
||||
runAction(async () => {
|
||||
await setProjectMemberAdmin(project.id, member.user_id, member.role_in_project !== 'admin');
|
||||
onChanged();
|
||||
});
|
||||
}
|
||||
|
||||
function confirmRemoveMember(member) {
|
||||
openDialog({
|
||||
tone: 'warning',
|
||||
@@ -48,19 +60,36 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
||||
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
|
||||
<Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button>
|
||||
</div>
|
||||
{project.members.map((m) => (
|
||||
{project.members.map((m) => {
|
||||
const badge = ROLE_BADGE[m.role_in_project];
|
||||
return (
|
||||
<div key={m.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
<span style={{ flex: 1, fontSize: '.82rem' }}>{m.name} <span className="text-muted">· {m.email}</span></span>
|
||||
{badge && <Badge color={badge.color}>{t(badge.key)}</Badge>}
|
||||
{m.role_in_project !== 'owner' && (
|
||||
<button
|
||||
className="icon-btn"
|
||||
title={m.role_in_project === 'admin' ? t('project_remove_admin') : t('project_make_admin')}
|
||||
onClick={() => toggleAdmin(m)}
|
||||
>
|
||||
{m.role_in_project === 'admin' ? <ShieldOff size={16} /> : <ShieldCheck size={16} />}
|
||||
</button>
|
||||
)}
|
||||
<button className="icon-btn" onClick={() => confirmRemoveMember(m)}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
||||
</div>
|
||||
|
||||
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={260}>
|
||||
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={300}>
|
||||
<Field label={t('project_add_member')}>
|
||||
<ComboBox options={userOptions} value={addingMember} onChange={setAddingMember} placeholder={t('project_add_member')} />
|
||||
</Field>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={addAsAdmin} onChange={(e) => setAddAsAdmin(e.target.checked)} />
|
||||
{t('project_add_member_as_admin')}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button variant="accent" icon={UserPlus} onClick={addMember}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
|
||||
|
||||
@@ -11,5 +11,6 @@ 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 setProjectMemberAdmin = (id, userId, admin) => api.put(`/projects/${id}/members/${userId}/role`, { admin });
|
||||
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