From 1d22adc42ea698b0f51bb260fffe5391ce4b2c2b Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Thu, 3 Sep 2026 08:37:58 -0500 Subject: [PATCH] chore: estructura inicial del MVP Backend (Express + MariaDB) y frontend (React + Vite, identidad Fluent/Windows) con el nucleo funcional: seguridad, organizacion, proyectos, actividades, subtareas, dependencias, dashboard, calendario, gantt, comentarios, adjuntos, notificaciones in-app y catalogos. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 63 + AGENTS.md | 157 ++ API.md | 79 + BASE_DE_DATOS.md | 147 ++ CHANGELOG.md | 8 + CONFIGURACION.md | 84 + ESTADO.md | 84 + README.md | 78 + SPEC.md | 110 + VERSION | 1 + VERSIONADO.md | 33 + backend/.env.example | 15 + backend/database/migrations/001_initial.sql | 196 ++ backend/package-lock.json | 1248 +++++++++++ backend/package.json | 24 + backend/src/app.js | 42 + backend/src/config/db.js | 13 + backend/src/config/env.js | 32 + backend/src/middleware/auth.js | 58 + backend/src/middleware/errorHandler.js | 18 + backend/src/middleware/upload.js | 43 + backend/src/modules/activities/controller.js | 28 + backend/src/modules/activities/repository.js | 103 + backend/src/modules/activities/routes.js | 13 + backend/src/modules/activities/service.js | 130 ++ backend/src/modules/attachments/controller.js | 51 + backend/src/modules/attachments/repository.js | 30 + backend/src/modules/attachments/routes.js | 14 + backend/src/modules/audit/controller.js | 7 + backend/src/modules/audit/repository.js | 16 + backend/src/modules/audit/routes.js | 10 + backend/src/modules/auth/controller.js | 18 + backend/src/modules/auth/routes.js | 19 + backend/src/modules/auth/service.js | 36 + backend/src/modules/catalogs/controller.js | 43 + backend/src/modules/catalogs/repository.js | 47 + backend/src/modules/catalogs/routes.js | 13 + backend/src/modules/comments/controller.js | 24 + backend/src/modules/comments/repository.js | 24 + backend/src/modules/comments/routes.js | 11 + backend/src/modules/dashboard/controller.js | 35 + backend/src/modules/dashboard/repository.js | 72 + backend/src/modules/dashboard/routes.js | 11 + backend/src/modules/departments/controller.js | 17 + backend/src/modules/departments/repository.js | 33 + backend/src/modules/departments/routes.js | 13 + backend/src/modules/departments/service.js | 43 + .../src/modules/notifications/controller.js | 20 + .../src/modules/notifications/repository.js | 31 + backend/src/modules/notifications/routes.js | 12 + backend/src/modules/notifications/service.js | 7 + backend/src/modules/projects/controller.js | 29 + backend/src/modules/projects/repository.js | 119 + backend/src/modules/projects/routes.js | 20 + backend/src/modules/projects/service.js | 73 + backend/src/modules/subtasks/controller.js | 23 + backend/src/modules/subtasks/repository.js | 49 + backend/src/modules/subtasks/routes.js | 14 + backend/src/modules/subtasks/service.js | 59 + backend/src/modules/users/controller.js | 20 + backend/src/modules/users/repository.js | 54 + backend/src/modules/users/routes.js | 14 + backend/src/modules/users/service.js | 41 + backend/src/scripts/migrate-db.js | 34 + backend/src/scripts/seed-db.js | 108 + backend/src/server.js | 6 + backend/src/utils/audit.js | 8 + backend/src/utils/code.js | 4 + backend/src/utils/dates.js | 19 + backend/src/utils/http.js | 18 + docs/SEGURIDAD-Y-CALIDAD.md | 109 + docs/UI-UX-WINDOWS.html | 1265 +++++++++++ docs/UI-UX-WINDOWS.md | 273 +++ docs/chart.umd.js | 14 + docs/lucide.min.js | 12 + frontend/.env.example | 1 + frontend/index.html | 12 + frontend/package-lock.json | 1925 +++++++++++++++++ frontend/package.json | 21 + frontend/src/app/App.jsx | 36 + frontend/src/app/AppShell.jsx | 94 + frontend/src/app/ProtectedRoute.jsx | 9 + frontend/src/components/AttachmentsPanel.jsx | 58 + frontend/src/components/Badge.jsx | 9 + frontend/src/components/Button.jsx | 9 + frontend/src/components/ComboBox.jsx | 59 + frontend/src/components/CommentsPanel.jsx | 40 + frontend/src/components/ContentDialog.jsx | 38 + frontend/src/components/Expander.jsx | 19 + frontend/src/components/Field.jsx | 13 + frontend/src/components/Flyout.jsx | 57 + frontend/src/components/InfoBar.jsx | 21 + frontend/src/components/KpiCard.jsx | 8 + frontend/src/components/ListView.jsx | 72 + frontend/src/components/NavigationView.jsx | 31 + frontend/src/components/SearchInput.jsx | 12 + frontend/src/components/StatusBadge.jsx | 26 + frontend/src/components/Tabs.jsx | 11 + frontend/src/components/ToastHost.jsx | 13 + frontend/src/components/Treeview.jsx | 39 + frontend/src/context/auth-context.jsx | 44 + frontend/src/context/ui-context.jsx | 65 + frontend/src/hooks/useAsync.js | 40 + frontend/src/i18n/messages.js | 270 +++ frontend/src/main.jsx | 11 + .../src/pages/Configuracion/CatalogEditor.jsx | 127 ++ .../pages/Configuracion/ConfiguracionPage.jsx | 89 + .../src/pages/Dashboard/DashboardPage.jsx | 57 + frontend/src/pages/Login/LoginPage.jsx | 52 + .../pages/Organizacion/DepartmentsPanel.jsx | 107 + .../pages/Organizacion/OrganizacionPage.jsx | 25 + .../src/pages/Organizacion/UsersPanel.jsx | 143 ++ .../pages/Proyectos/ActivityDetailPanel.jsx | 222 ++ .../pages/Proyectos/ProjectActivitiesTab.jsx | 144 ++ .../pages/Proyectos/ProjectCalendarTab.jsx | 66 + .../src/pages/Proyectos/ProjectGanttTab.jsx | 39 + .../src/pages/Proyectos/ProjectInfoTab.jsx | 204 ++ .../pages/Proyectos/ProyectoDetallePage.jsx | 59 + .../src/pages/Proyectos/ProyectosPage.jsx | 154 ++ frontend/src/services/activities.js | 8 + frontend/src/services/api.js | 52 + frontend/src/services/attachments.js | 30 + frontend/src/services/catalogs.js | 6 + frontend/src/services/comments.js | 4 + frontend/src/services/dashboard.js | 4 + frontend/src/services/departments.js | 6 + frontend/src/services/notifications.js | 5 + frontend/src/services/projects.js | 12 + frontend/src/services/subtasks.js | 7 + frontend/src/services/users.js | 7 + frontend/src/styles/app.css | 275 +++ frontend/src/styles/tokens.css | 47 + frontend/src/utils/dueStatus.js | 14 + frontend/vite.config.js | 12 + scripts/set-version.sh | 24 + 135 files changed, 10913 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 API.md create mode 100644 BASE_DE_DATOS.md create mode 100644 CHANGELOG.md create mode 100644 CONFIGURACION.md create mode 100644 ESTADO.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 VERSION create mode 100644 VERSIONADO.md create mode 100644 backend/.env.example create mode 100644 backend/database/migrations/001_initial.sql create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/src/app.js create mode 100644 backend/src/config/db.js create mode 100644 backend/src/config/env.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/middleware/errorHandler.js create mode 100644 backend/src/middleware/upload.js create mode 100644 backend/src/modules/activities/controller.js create mode 100644 backend/src/modules/activities/repository.js create mode 100644 backend/src/modules/activities/routes.js create mode 100644 backend/src/modules/activities/service.js create mode 100644 backend/src/modules/attachments/controller.js create mode 100644 backend/src/modules/attachments/repository.js create mode 100644 backend/src/modules/attachments/routes.js create mode 100644 backend/src/modules/audit/controller.js create mode 100644 backend/src/modules/audit/repository.js create mode 100644 backend/src/modules/audit/routes.js create mode 100644 backend/src/modules/auth/controller.js create mode 100644 backend/src/modules/auth/routes.js create mode 100644 backend/src/modules/auth/service.js create mode 100644 backend/src/modules/catalogs/controller.js create mode 100644 backend/src/modules/catalogs/repository.js create mode 100644 backend/src/modules/catalogs/routes.js create mode 100644 backend/src/modules/comments/controller.js create mode 100644 backend/src/modules/comments/repository.js create mode 100644 backend/src/modules/comments/routes.js create mode 100644 backend/src/modules/dashboard/controller.js create mode 100644 backend/src/modules/dashboard/repository.js create mode 100644 backend/src/modules/dashboard/routes.js create mode 100644 backend/src/modules/departments/controller.js create mode 100644 backend/src/modules/departments/repository.js create mode 100644 backend/src/modules/departments/routes.js create mode 100644 backend/src/modules/departments/service.js create mode 100644 backend/src/modules/notifications/controller.js create mode 100644 backend/src/modules/notifications/repository.js create mode 100644 backend/src/modules/notifications/routes.js create mode 100644 backend/src/modules/notifications/service.js create mode 100644 backend/src/modules/projects/controller.js create mode 100644 backend/src/modules/projects/repository.js create mode 100644 backend/src/modules/projects/routes.js create mode 100644 backend/src/modules/projects/service.js create mode 100644 backend/src/modules/subtasks/controller.js create mode 100644 backend/src/modules/subtasks/repository.js create mode 100644 backend/src/modules/subtasks/routes.js create mode 100644 backend/src/modules/subtasks/service.js create mode 100644 backend/src/modules/users/controller.js create mode 100644 backend/src/modules/users/repository.js create mode 100644 backend/src/modules/users/routes.js create mode 100644 backend/src/modules/users/service.js create mode 100644 backend/src/scripts/migrate-db.js create mode 100644 backend/src/scripts/seed-db.js create mode 100644 backend/src/server.js create mode 100644 backend/src/utils/audit.js create mode 100644 backend/src/utils/code.js create mode 100644 backend/src/utils/dates.js create mode 100644 backend/src/utils/http.js create mode 100644 docs/SEGURIDAD-Y-CALIDAD.md create mode 100644 docs/UI-UX-WINDOWS.html create mode 100644 docs/UI-UX-WINDOWS.md create mode 100644 docs/chart.umd.js create mode 100644 docs/lucide.min.js create mode 100644 frontend/.env.example create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/app/App.jsx create mode 100644 frontend/src/app/AppShell.jsx create mode 100644 frontend/src/app/ProtectedRoute.jsx create mode 100644 frontend/src/components/AttachmentsPanel.jsx create mode 100644 frontend/src/components/Badge.jsx create mode 100644 frontend/src/components/Button.jsx create mode 100644 frontend/src/components/ComboBox.jsx create mode 100644 frontend/src/components/CommentsPanel.jsx create mode 100644 frontend/src/components/ContentDialog.jsx create mode 100644 frontend/src/components/Expander.jsx create mode 100644 frontend/src/components/Field.jsx create mode 100644 frontend/src/components/Flyout.jsx create mode 100644 frontend/src/components/InfoBar.jsx create mode 100644 frontend/src/components/KpiCard.jsx create mode 100644 frontend/src/components/ListView.jsx create mode 100644 frontend/src/components/NavigationView.jsx create mode 100644 frontend/src/components/SearchInput.jsx create mode 100644 frontend/src/components/StatusBadge.jsx create mode 100644 frontend/src/components/Tabs.jsx create mode 100644 frontend/src/components/ToastHost.jsx create mode 100644 frontend/src/components/Treeview.jsx create mode 100644 frontend/src/context/auth-context.jsx create mode 100644 frontend/src/context/ui-context.jsx create mode 100644 frontend/src/hooks/useAsync.js create mode 100644 frontend/src/i18n/messages.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/Configuracion/CatalogEditor.jsx create mode 100644 frontend/src/pages/Configuracion/ConfiguracionPage.jsx create mode 100644 frontend/src/pages/Dashboard/DashboardPage.jsx create mode 100644 frontend/src/pages/Login/LoginPage.jsx create mode 100644 frontend/src/pages/Organizacion/DepartmentsPanel.jsx create mode 100644 frontend/src/pages/Organizacion/OrganizacionPage.jsx create mode 100644 frontend/src/pages/Organizacion/UsersPanel.jsx create mode 100644 frontend/src/pages/Proyectos/ActivityDetailPanel.jsx create mode 100644 frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx create mode 100644 frontend/src/pages/Proyectos/ProjectCalendarTab.jsx create mode 100644 frontend/src/pages/Proyectos/ProjectGanttTab.jsx create mode 100644 frontend/src/pages/Proyectos/ProjectInfoTab.jsx create mode 100644 frontend/src/pages/Proyectos/ProyectoDetallePage.jsx create mode 100644 frontend/src/pages/Proyectos/ProyectosPage.jsx create mode 100644 frontend/src/services/activities.js create mode 100644 frontend/src/services/api.js create mode 100644 frontend/src/services/attachments.js create mode 100644 frontend/src/services/catalogs.js create mode 100644 frontend/src/services/comments.js create mode 100644 frontend/src/services/dashboard.js create mode 100644 frontend/src/services/departments.js create mode 100644 frontend/src/services/notifications.js create mode 100644 frontend/src/services/projects.js create mode 100644 frontend/src/services/subtasks.js create mode 100644 frontend/src/services/users.js create mode 100644 frontend/src/styles/app.css create mode 100644 frontend/src/styles/tokens.css create mode 100644 frontend/src/utils/dueStatus.js create mode 100644 frontend/vite.config.js create mode 100755 scripts/set-version.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc159 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# --- Secrets & env --- +.env +.env.* +!.env.example +backend/.env +backend/.env.* +!backend/.env.example +frontend/.env +frontend/.env.* +!frontend/.env.example + +# --- Dependencies --- +node_modules/ +backend/node_modules/ +frontend/node_modules/ + +# --- Build outputs --- +dist/ +build/ +coverage/ +frontend/dist/ +backend/dist/ + +# --- Logs --- +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +backend/logs/ + +# --- Persistent app data --- +storage/ +backend/storage/ +backend/uploads/ +backend/tmp/ +backend/temp/ + +# --- Editor / OS --- +.DS_Store +Thumbs.db +.token_gitea +.idea/ +.codex +.claude/ +.tmp-venv/ +.vscode/* +!.vscode/extensions.json +*.swp +*.swo + +# --- Local artifacts / references (NUNCA versionar) --- +Screenshot_*.png +test.pdf +LOCAL_DEPLOY_CONTEXT.md +SERVIDORES.md +PORTS_INVENTORY.md + +# --- Certificados / llaves --- +backend/*.p12 +backend/*.pfx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..55d6bdb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,157 @@ +# AGENTS.md - project.linkedcorp.net + +> **⚠️ ANTES DE ACTUAR, lee el orquestador del grupo:** `/home/mgonzalez/desarrollo/project-md-templates/leeme_primero.txt` +> Reglas globales (infra, secretos, UI/UX, Android, Git, versionado). Este archivo es solo el contexto especifico del proyecto. + +## ⛔ AISLAMIENTO: SOLO ESTE PROYECTO + +Regla dura del grupo (detalle en el orquestador, seccion "REGLA DE AISLAMIENTO ENTRE PROYECTOS"): + +- Se trabaja SOLO sobre este proyecto. Los otros proyectos del grupo NO se tocan NI se leen. +- Leer otro proyecto: unicamente si el usuario lo pide TEXTUALMENTE en ese pedido. +- Modificar otro proyecto: NUNCA, ni aunque el usuario lo nombre. +- Recursos compartidos (puertos, NPM, systemd, BD, DNS): verificar en la fuente real que esten + libres antes de ocuparlos; solo crear objetos nuevos de ESTE proyecto. + +## Proposito + +Contexto especifico del proyecto para el asistente de IA. No repite las reglas globales del grupo, +que estan en el orquestador `leeme_primero.txt`. + +## Identidad del proyecto + +- Nombre: `project.linkedcorp.net` +- Tipo: fullstack (gestión y seguimiento de proyectos/actividades TI) +- Estado: development +- Ruta raiz: `/home/mgonzalez/desarrollo/project.linkedcorp.net` + +## Stack real del proyecto + +### Frontend +- React + Vite +- UI: CSS propio por tokens (sin Tailwind) siguiendo la identidad `docs/UI-UX-WINDOWS.md` (Fluent/Windows 11) +- Iconos: `lucide-react` + +### Backend +- Node.js + Express +- API base: `/api` + +### Base de datos +- Motor: MariaDB +- Base principal: `project_linkedcorp` +- Host dev: `192.168.222.20` / Host prod: `192.168.222.32` (NO mezclar) +- La base ya existe: sí (creada en dev el 2026-09-03 con `crear-db-dev.sh`) + +## Arquetipo de despliegue + +**B — Dos servicios separados** (frontend y backend como procesos independientes). +- systemd: `project-linkedcorp-frontend` y `project-linkedcorp-backend` (a crear en el primer deploy). +- NPM: Proxy Host al frontend + Custom Location `/api` al backend. +- Puertos (registrados en `PORTS_INVENTORY.md` del grupo): frontend `5196`, backend `4019`. + +## Identidad UI/UX — DECISIÓN DE ESTE PROYECTO (no es la identidad estándar del grupo) + +Este proyecto usa `UI-UX-WINDOWS.md` / `UI-UX-WINDOWS.html` (Fluent Design / Windows 11: +NavigationView, clic derecho, Master-Detail, ContentDialog/InfoBar/Flyout/Toast) en vez de +`UI-UX-LINKEDCORP.md` (identidad web estándar del grupo). Fue decisión explícita del usuario al +arrancar el proyecto. Copia ajustada en `docs/UI-UX-WINDOWS.md`. + +Adaptación web (no es una app empaquetada, corre en pestaña de navegador): +- SÍ: tokens de color, tipografía, NavigationView, ListView+clic derecho, Master-Detail, + ContentDialog, InfoBar, Toast, Flyout, ComboBox/DatePicker propios, Expander, Treeview. +- NO: barra de título falsa con minimizar/maximizar/cerrar (no hay ventana de SO que controlar). +- Igual obligatorio: idioma es-EC/en-US + tema claro/oscuro + apariencia de botones, todo persistido. +- Set de íconos: Lucide (única opción usada en este proyecto). + +## Estructura acordada + +- `frontend/` +- `backend/` +- `docs/` + +## Modulos principales (MVP — ver SPEC.md para el detalle y lo diferido a fase 2) + +- Seguridad (usuarios, roles fijos, sesión, auditoría) +- Organización (departamentos con sub-áreas, usuarios) +- Proyectos (con miembros) +- Actividades + Subtareas + dependencias simples +- Dashboard (KPIs) +- Calendario y Gantt de solo lectura +- Comentarios y adjuntos +- Notificaciones in-app +- Configuración (catálogos: estados, prioridades, tipos de proyecto) + +## Restricciones tecnicas + +- No romper estructura existente. +- Mantener frontend y backend separados (arquetipo B). +- Variables de entorno, nunca secretos en código. +- Empresa asumida como única (single-company): no hay tabla `companies` en el MVP. +- Permisos por rol codificados en backend (`backend/src/middleware/auth.js`), no configurables por + UI en el MVP — ver tabla de roles en SPEC.md. + +## Arquitectura esperada + +### Backend +- `routes -> controller -> service -> repository -> db` +- Un directorio por módulo en `backend/src/modules//` + +### Frontend +- `src/components` (UI base Windows: NavigationView, ContentDialog, InfoBar, Toast, Flyout, ListView, ComboBox, DatePicker, Treeview, Field, Button) +- `src/pages` (Login, Dashboard, Organizacion, Proyectos, Configuracion) +- `src/services` (cliente API por módulo) +- `src/context` (auth, ui: tema/idioma/dialog/toast) +- `src/i18n` + +## Seguridad (proyecto con auth/usuarios) + +- Permisos validados SIEMPRE en el backend, nunca solo en el frontend. +- Sin autosuscripción: el administrador crea los usuarios. +- `401` → el frontend limpia sesión, redirige a login y muestra aviso (ContentDialog). +- Cierre de sesión por expiración de JWT. +- Auditoría en BD (`audit_log`): usuario, acción, entidad, fecha/hora, detalle. +- Credenciales/secretos solo en `.env` (nunca en código); `.env.example` sí se versiona. +- Cifrado: bcrypt para contraseñas. +- Revalidar `is_active` del usuario en cada request (un JWT firmado no basta). +- SQL siempre parametrizado. Uploads: validar MIME/tamaño, ruta de archivo generada por el servidor. + +## Regla de UX para pantallas + +- No mezclar listado y formulario de creación en la vista inicial de un módulo. +- Primero el listado; botón "Nuevo" abre creación. +- Detalle abre en modo lectura; "Editar" habilita campos; "Cancelar" vuelve a lectura sin guardar; + guardar solo si hubo cambios reales. +- Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos) + van en pestañas reales, nunca apiladas ni navegando a otra pantalla. + +## Flujos criticos + +1. Login → Dashboard con KPIs del usuario. +2. Crear proyecto → asignar miembros → crear actividades → crear subtareas → avance se recalcula solo. +3. Actividad vencida o por vencer → notificación in-app al responsable. + +## Entorno operativo + +- Desarrollo: `npm run dev` en `backend/` (puerto 4019) y `frontend/` (puerto 5196, proxy `/api`). +- Producción: pendiente (deploy explícito posterior, ver PRODUCCION.md cuando se cree). +- Docker por defecto: no. + +## Regla de versionado + +- Versionado `X.Y.Z` (ver VERSIONADO.md). +- Antes de producción, validar si corresponde subir versión. +- Toda versión nueva se refleja en `VERSION` y `CHANGELOG.md`. + +## Documentacion relacionada + +- `README.md`, `SPEC.md`, `BASE_DE_DATOS.md`, `CONFIGURACION.md`, `API.md`, `VERSIONADO.md` +- `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md` +- `ESTADO.md` (handoff — leer siempre al retomar el proyecto) + +## Pendientes para quedar listo + +- Fase 2: riesgos/incidencias como módulos propios, solicitudes formales, recursos/proveedores, + presupuesto y costos, registro de horas detallado, reportes exportables (PDF/Excel/CSV), + permisos configurables por módulo×acción, notificaciones por email. +- Deploy a producción (servidor .3, BD .32, systemd, NPM) — confirmar con el usuario cuando el MVP + esté validado en local. diff --git a/API.md b/API.md new file mode 100644 index 0000000..fb544e8 --- /dev/null +++ b/API.md @@ -0,0 +1,79 @@ +# API - project.linkedcorp.net + +## Base URL + +- Desarrollo: `http://localhost:4019/api` +- Producción: `https://project.linkedcorp.net/api` (pendiente de deploy) + +## Autenticacion + +- Tipo: Bearer JWT (`Authorization: Bearer `), emitido por `/api/auth/login`. +- El backend revalida `is_active` del usuario en cada request (no solo la firma del JWT). + +## Formato de respuesta + +```json +{ + "success": true, + "data": {}, + "error": null +} +``` + +## Endpoints principales + +### Auth +- `POST /api/auth/login` — `{ email, password }` → `{ token, user }` +- `POST /api/auth/logout` +- `GET /api/auth/me` — usuario actual + +### Usuarios (`/api/users`) — solo Administrador +- `GET /api/users` · `POST /api/users` · `GET /api/users/:id` · `PUT /api/users/:id` · `PATCH /api/users/:id/active` + +### Departamentos (`/api/departments`) +- `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/:id` · `PUT /api/projects/:id` +- `POST /api/projects/:id/members` · `DELETE /api/projects/:id/members/:userId` + +### Actividades (`/api/activities`) +- `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities` +- `GET /api/activities/:id` · `PUT /api/activities/:id` +- `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId` + +### Subtareas (`/api/activities/:activityId/subtasks`) +- `GET` · `POST` · `PUT /api/subtasks/:id` · `PATCH /api/subtasks/:id/done` · `DELETE /api/subtasks/:id` + +### Comentarios (`/api/comments`) +- `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments` + +### Adjuntos (`/api/attachments`) +- `GET /api/attachments?entityType=activity&entityId=1` · `POST /api/attachments` (multipart) +- `GET /api/attachments/:id/download` · `DELETE /api/attachments/:id` + +### Notificaciones (`/api/notifications`) +- `GET /api/notifications` · `PATCH /api/notifications/:id/read` · `PATCH /api/notifications/read-all` + +### Dashboard (`/api/dashboard`) +- `GET /api/dashboard/summary` — KPIs (activos, atrasados, pendientes/vencidas/hoy, avance promedio) +- `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 +- `GET/POST/PUT/DELETE /api/catalogs/project-statuses` +- `GET/POST/PUT/DELETE /api/catalogs/activity-statuses` +- `GET/POST/PUT/DELETE /api/catalogs/priorities` +- `GET/POST/PUT/DELETE /api/catalogs/project-types` + +### Auditoría (`/api/audit`) — solo Administrador / Jefe de TI +- `GET /api/audit?entityType=project&entityId=1` + +## Errores esperados + +- `400` validación +- `401` no autenticado / token inválido o expirado +- `403` sin permisos para la acción (según rol) +- `404` no encontrado (incluye cuando el recurso existe pero no pertenece al usuario/alcance) +- `500` error interno (sanitizado, sin detalle de stack en producción) diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md new file mode 100644 index 0000000..d706492 --- /dev/null +++ b/BASE_DE_DATOS.md @@ -0,0 +1,147 @@ +# Base de Datos - project.linkedcorp.net + +## Motor + +- Motor: MariaDB +- Base principal: `project_linkedcorp` +- Host desarrollo: `192.168.222.20` +- Host producción: `192.168.222.32` +- Puerto: `3306` +- Charset: `utf8mb4` +- Collation: `utf8mb4_unicode_ci` + +## Fuente de verdad + +- La base ya existe: sí (creada en dev el 2026-09-03, usuario `project_linkedcorp`). +- Archivo SQL base: `backend/database/migrations/` (SQL idempotente, numerado, `CREATE TABLE IF NOT EXISTS`). +- Seeds: `backend/database/seeds/` (roles, catálogos, usuario admin de dev). + +## Tablas principales (MVP) + +| Tabla | Contenido | +|---|---| +| `roles` | catálogo fijo de 5 roles (admin, it_manager, project_lead, collaborator, viewer) | +| `users` | usuarios, con rol y departamento | +| `departments` | departamentos/áreas, jerarquía simple vía `parent_id` | +| `project_types` | catálogo de tipos de proyecto | +| `priorities` | catálogo de prioridades (baja/media/alta/crítica) | +| `project_statuses` | catálogo de estados de proyecto | +| `activity_statuses` | catálogo de estados de actividad | +| `projects` | proyectos | +| `project_members` | usuarios asignados a un proyecto (reemplaza "equipos" como módulo aparte) | +| `activities` | actividades de un proyecto | +| `activity_dependencies` | dependencias simples entre actividades ("depende de") | +| `subtasks` | checklist liviano dentro de una actividad | +| `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) | +| `attachments` | archivos adjuntos a proyecto o actividad | +| `notifications` | notificaciones in-app por usuario | +| `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) | + +## Relaciones clave + +- `users.role_id -> roles.id`, `users.department_id -> departments.id` +- `departments.parent_id -> departments.id` (jerarquía opcional, ej. TI → Desarrollo) +- `projects.status_id -> project_statuses.id`, `projects.priority_id -> priorities.id`, + `projects.project_type_id -> project_types.id`, `projects.owner_user_id -> users.id`, + `projects.requesting_department_id / responsible_department_id -> departments.id` +- `project_members.project_id -> projects.id`, `project_members.user_id -> users.id` +- `activities.project_id -> projects.id`, `activities.status_id -> activity_statuses.id`, + `activities.priority_id -> priorities.id`, `activities.responsible_user_id -> users.id` +- `activity_dependencies.activity_id / depends_on_activity_id -> activities.id` +- `subtasks.activity_id -> activities.id`, `subtasks.responsible_user_id -> users.id` (nullable) +- `comments.entity_id` / `attachments.entity_id` apuntan a `projects.id` o `activities.id` según + `entity_type` (sin FK física cruzada — se valida en el servicio) + +## Reglas de datos + +- `projects.progress_pct` y `activities.progress_pct` son **calculados por el servicio**, no + editables directo por el cliente: actividad con subtareas = % de subtareas hechas; actividad sin + subtareas = manual (0-100); proyecto = promedio del avance de sus actividades. +- `users.password_hash` con bcrypt, nunca texto plano ni reversible. +- Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios con historial. +- 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. + +## Riesgos o decisiones + +- Sin triggers de BD para recalcular avances: toda la lógica vive en la capa `service/` del backend + (más simple de depurar y de extender en fase 2). +- `risk_level` es un campo simple en `projects` (bajo/medio/alto) — el módulo completo de Riesgos + (probabilidad/impacto/mitigación) queda para fase 2, sin necesitar migrar este campo. + +## Archivos relacionados + +- `backend/.env.example` +- `backend/database/migrations/*.sql` +- `backend/database/seeds/*.sql` + +--- + +## Crear la base de datos en DESARROLLO (.20) + +Ya ejecutado para este proyecto (2026-09-03): + +```bash +bash /home/mgonzalez/desarrollo/project-md-templates/crear-db-dev.sh project_linkedcorp project_linkedcorp +``` + +BD `project_linkedcorp` y usuario `project_linkedcorp`@`192.168.222.40` creados en `192.168.222.20`. +Las credenciales están en `backend/.env` (no versionado). + +Verificar: + +```bash +mysql --defaults-extra-file=~/.config/mysql/dev20-admin.cnf -e "SHOW DATABASES LIKE 'project_linkedcorp';" +``` + +Aplicar migraciones/seeds (una vez creado `backend/src/scripts/migrate-db.js`): + +```bash +cd backend +set -a && source .env && set +a +node src/scripts/migrate-db.js +node src/scripts/seed-db.js +``` + +--- + +## Setup de base de datos en producción (primera vez) — pendiente, no ejecutado aún + +El servidor `.32` no tiene SSH accesible; el acceso admin es solo desde la máquina de desarrollo. +Credenciales de acceso admin: ver `SERVIDORES.md` (project-md-templates/, no versionado). + +```bash +mysql -h 192.168.222.32 -u mgonzalez -p --ssl=0 +``` + +```sql +CREATE DATABASE IF NOT EXISTS project_linkedcorp + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +CREATE USER IF NOT EXISTS 'project_linkedcorp'@'192.168.222.3' + IDENTIFIED BY '[DB_PASSWORD_PROD]'; + +GRANT ALL PRIVILEGES ON project_linkedcorp.* TO 'project_linkedcorp'@'192.168.222.3'; + +FLUSH PRIVILEGES; +``` + +Migración en el servidor de aplicaciones (`.3`), cuando exista el deploy: + +```bash +ssh deploy@192.168.222.3 -p 2203 +cd /opt/apps/project.linkedcorp.net/source/backend +set -a && source /opt/apps/project.linkedcorp.net/backend/.env && set +a +node src/scripts/migrate-db.js +node src/scripts/seed-db.js # solo catálogos/roles; NO recrear usuarios de prueba de dev +``` + +--- + +## Actualizaciones de esquema en producción + +Método: archivos SQL idempotentes en `backend/database/migrations/00X-nombre.sql` +(`CREATE TABLE IF NOT EXISTS` / `information_schema` guards), aplicados con +`node src/scripts/migrate-db.js`. Siempre backup (`mysqldump --single-transaction`) antes de migrar +en producción. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6295758 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog - project.linkedcorp.net + +## [0.1.0] - 2026-09-03 +### Added +- Estructura inicial del proyecto (backend Express + frontend React/Vite + MariaDB). +- Modelo de datos MVP: usuarios/roles, departamentos, proyectos, actividades, subtareas, + dependencias, comentarios, adjuntos, notificaciones, auditoría, catálogos. +- Identidad UI/UX Fluent/Windows 11 (UI-UX-WINDOWS) adaptada a app web. diff --git a/CONFIGURACION.md b/CONFIGURACION.md new file mode 100644 index 0000000..879f066 --- /dev/null +++ b/CONFIGURACION.md @@ -0,0 +1,84 @@ +# Configuracion - project.linkedcorp.net + +## Requisitos + +- Node.js: 20.x LTS +- npm: 10.x +- MariaDB: servidor dev `192.168.222.20` (ya existe la BD `project_linkedcorp`) + +## Variables principales + +Usar `backend/.env.example` y `frontend/.env.example` como base. + +- copiar `.env.example` -> `.env` en cada servicio +- completar `.env` real antes de pruebas de conexión +- leer la configuración desde `.env` +- versionar `.env.example`; nunca versionar `.env` + +`backend/.env`: + +```env +PORT=4019 +NODE_ENV=development + +DB_HOST=192.168.222.20 +DB_PORT=3306 +DB_NAME=project_linkedcorp +DB_USER=project_linkedcorp +DB_PASSWORD= + +JWT_SECRET= +JWT_EXPIRES=8h + +CORS_ORIGIN=http://localhost:5196 + +STORAGE_ROOT=./storage +``` + +`frontend/.env`: + +```env +VITE_API_URL=/api +``` + +## Setup local + +### Backend + +```bash +cd backend +npm install +npm run dev +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +**React + Vite — proxy de desarrollo y acceso desde el teléfono** (`frontend/vite.config.js`): en +dev, el frontend reenvía `/api` al backend (evita CORS) y escucha en `0.0.0.0` para probar desde el +celular. En producción, cada servicio corre por separado (arquetipo B): el frontend sirve su propio +`dist` y el backend expone `/api`; NPM enruta `/api` al backend en el dominio público. + +```js +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5196, + strictPort: true, + proxy: { '/api': 'http://localhost:4019' }, + }, +}) +``` + +## Verificaciones + +- Frontend responde en `5196` (`http://192.168.222.20x:5196` o `localhost:5196`). +- Backend responde en `4019` (`curl http://localhost:4019/health`). +- Conexión a base válida (`node -e "require('./src/config/db')" ` o el log de arranque del servidor). +- `.env.example` alineado con el código (nuevas variables se agregan también ahí). diff --git a/ESTADO.md b/ESTADO.md new file mode 100644 index 0000000..87e950f --- /dev/null +++ b/ESTADO.md @@ -0,0 +1,84 @@ +# ESTADO — project.linkedcorp.net + +Ultima actualizacion: 2026-09-03 +Fase actual: MVP funcional en desarrollo local (fase 1 completa, sin deploy a produccion). + +## Como retomar + +1. Leer primero el orquestador: `/home/mgonzalez/desarrollo/project-md-templates/leeme_primero.txt`. +2. Leer `AGENTS.md`, `SPEC.md` y este archivo. +3. Backend: `cd backend && npm install && npm run dev` (puerto 4019). Requiere `backend/.env` + (no versionado — si falta, copiar `backend/.env.example` y completar con las credenciales de + la BD dev `project_linkedcorp` en `192.168.222.20`, usuario `project_linkedcorp`; la clave + generada la tiene el usuario, no esta en este archivo). +4. Frontend: `cd frontend && npm install && npm run dev` (puerto 5196, con proxy `/api` al backend). +5. Login de prueba (dev): `mgonzalez@linkedcorp.net` / clave `mgonzalez` (rol Administrador). +6. Migraciones/seed ya aplicados en la BD dev. Si se recrea la BD: `npm run migrate && npm run seed` + dentro de `backend/`. + +## Hecho y verificado (2026-09-03) + +- Repo/estructura inicial: `backend/` (Express + mysql2, arquitectura routes→controller→service→ + repository), `frontend/` (React + Vite, identidad Fluent/Windows), `docs/` (UI-UX-WINDOWS, + SEGURIDAD-Y-CALIDAD adaptada). +- Puertos reservados en `PORTS_INVENTORY.md` del grupo: frontend `5196`, backend `4019`. +- BD dev `project_linkedcorp` creada en `192.168.222.20` con `crear-db-dev.sh`; esquema aplicado + (`backend/database/migrations/001_initial.sql`) y seed cargado (roles, catalogos, departamentos + TI/Finanzas/RRHH/Comercial + sub-areas de TI, usuario admin de dev). +- Backend: modulos completos — auth (JWT + rate limit en login), users, departments, catalogs + (priorities/project-statuses/activity-statuses/project-types), projects (+ members), activities + (+ dependencies), subtasks, comments, attachments (multer, MIME whitelist), notifications, + dashboard (summary/calendar/gantt), audit. Recalculo automatico de `progress_pct` (actividad + desde subtareas, proyecto desde el promedio de sus actividades) verificado con pruebas manuales + via curl (login → crear proyecto → actividad → subtareas → avances correctos: 50%/50%). +- Frontend: identidad Fluent/Windows adaptada a web (sin barra de titulo falsa) — NavigationView, + ContentDialog, InfoBar, Toast, Flyout (menu contextual), ListView con orden/buscador, ComboBox + con buscador, Treeview, Tabs reales, Expander. Paginas: Login, Dashboard (KPIs + actividades + propias + proyectos por estado), Proyectos (listado + detalle con tabs Info/Actividades/Gantt/ + Calendario/Comentarios/Documentos, master-detail de actividades con subtareas/dependencias/ + comentarios/adjuntos), Organizacion (Departamentos en arbol + Usuarios), Configuracion + (apariencia + editor de los 4 catalogos). i18n es-EC/en-US, tema claro/oscuro y modo de botones + persistidos y funcionando. +- Verificacion end-to-end con Playwright (headless, instalado solo en el entorno de trabajo, no en + el proyecto): login, dashboard, listado y detalle de proyecto con las 6 pestañas, detalle de + actividad con subtareas, organizacion (arbol + usuarios), configuracion, cambio a tema claro, + creacion de un proyecto nuevo de punta a punta, y el ContentDialog de confirmacion de borrado — + todo sin errores de consola. Capturas no se conservaron en el repo (solo en el entorno temporal + de la sesion). + +## Pendiente (fase 2, sin comprometer lo ya construido) + +- Riesgos e Incidencias como modulos propios (hoy: `risk_level` simple en el proyecto). +- Solicitudes/requerimientos formales de otras areas. +- Recursos (servidores/licencias/software), Proveedores, Presupuesto y costos. +- Registro de horas trabajadas detallado (bitacora diaria). +- Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada. +- Permisos configurables por modulo × accion desde la UI (hoy: roles fijos codificados en + `backend/src/middleware/auth.js` y en cada `service.js`). +- Notificaciones por email/WhatsApp (hoy: solo in-app). +- Job programado para detectar actividades vencidas/por vencer y generar notificaciones + automaticas (hoy: la notificacion solo se dispara al asignar/reasignar una actividad). +- DatePicker/ComboBox: se uso `` nativo en vez de un flyout de calendario + propio (simplificacion deliberada para el MVP; UI-UX-WINDOWS.md recomienda uno propio). + +## Deploy a produccion — NO ejecutado + +Pendiente de que el usuario valide el MVP en local. Cuando se confirme, seguir +`BASE_DE_DATOS.md` (seccion produccion) y crear `PRODUCCION.md`/`DEPLOY_WEB.md` del proyecto +siguiendo el arquetipo B (dos servicios) descrito en el orquestador. + +## Decisiones clave + +- MVP simplificado respecto al listado original de 30 areas del usuario — detalle completo y + justificacion de cada simplificacion en `SPEC.md` (seccion "Fuera de alcance por ahora") y en el + plan de esta sesion. +- Identidad UI/UX: `UI-UX-WINDOWS.md` (Fluent/Windows 11) en vez de `UI-UX-LINKEDCORP.md` — + decision explicita del usuario. Adaptacion documentada en `AGENTS.md`. +- Empresa unica (single-company): no existe tabla `companies` en el MVP. +- Arquetipo de despliegue B (dos servicios separados) — decision explicita del usuario. + +## Deuda tecnica + +- `npm audit` en backend: 3 vulnerabilidades moderadas en `qs`/`body-parser`, transitivas de + Express 4.x (sin fix no-breaking disponible al 2026-09-03). No critico para uso interno; revisar + cuando Express publique el fix o evaluar migrar a Express 5 en fase 2. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad3150c --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# project.linkedcorp.net + +Gestión y seguimiento de proyectos y actividades de TI para una empresa de tamaño medio. MVP simple +por diseño (menos campos, menos módulos separados) con el modelo de datos preparado para crecer +hacia el resto del alcance funcional (riesgos, incidencias, recursos, proveedores, presupuesto, +reportes exportables, etc.) sin romper lo construido. + +## Stack + +- Frontend: React + Vite +- Backend: Node.js + Express +- Base de datos: MariaDB +- Deploy: Debian 12 (dos servicios separados — arquetipo B del grupo) + +## Estructura + +```text +project.linkedcorp.net/ +├── README.md +├── AGENTS.md +├── SPEC.md +├── API.md +├── BASE_DE_DATOS.md +├── CONFIGURACION.md +├── VERSIONADO.md +├── ESTADO.md +├── frontend/ +├── backend/ +├── docs/ +└── scripts/ +``` + +## Modulos principales (MVP) + +- Seguridad (usuarios, roles, auditoría) +- Organización (departamentos, usuarios) +- Proyectos, Actividades y Subtareas +- Dashboard, Calendario, Gantt (solo lectura) +- Comentarios, adjuntos, notificaciones in-app +- Configuración (catálogos) + +## Arranque rapido + +### Backend + +```bash +cd backend +npm install +cp .env.example .env # completar con las credenciales reales +npm run dev +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Puertos de este proyecto (registrados en `PORTS_INVENTORY.md` del grupo): frontend `5196`, +backend `4019`. El frontend escucha en `0.0.0.0` para poder probarlo desde otro equipo/celular en +la misma red. + +## Variables y configuracion + +- Revisar `backend/.env.example` y `CONFIGURACION.md`. +- Base de datos dev: `project_linkedcorp` en `192.168.222.20` (ya creada). + +## Documentacion relacionada + +- `AGENTS.md` — contexto del proyecto para el asistente de IA (leer primero el orquestador del grupo). +- `SPEC.md` — alcance funcional. +- `API.md` — contrato de la API. +- `BASE_DE_DATOS.md` — modelo de datos. +- `CONFIGURACION.md` — variables de entorno y setup local. +- `docs/UI-UX-WINDOWS.md` — identidad visual del proyecto (Fluent/Windows 11). +- `ESTADO.md` — punto de retoma del proyecto (handoff). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..64b45fb --- /dev/null +++ b/SPEC.md @@ -0,0 +1,110 @@ +# SPEC - project.linkedcorp.net + +## Objetivo + +Sistema interno para que una empresa de tamaño medio controle los proyectos y actividades de TI que +debe cumplir: qué proyectos hay, quién es responsable de qué, qué está atrasado y qué avance real +tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa día a día. + +## Problema que resuelve + +- Hoy no hay un lugar único donde ver proyectos activos, actividades pendientes/vencidas y de quién + es cada una. El Jefe de TI necesita esa foto al entrar, sin perseguir a cada persona. + +## Usuarios o roles + +| Rol | Alcance | +|---|---| +| Administrador | Todo, incluida gestión de usuarios/roles/catálogos | +| Jefe de TI | Ve y gestiona todos los proyectos/actividades, aprueba; no administra usuarios | +| Coordinador / Líder de proyecto | Crea/edita los proyectos donde es responsable o miembro, gestiona sus actividades | +| Colaborador | Ve y actualiza lo asignado a él (actividades/subtareas propias), comenta | +| Consultor | Solo lectura en todo | + +## Modulos (MVP) + +### 1. Seguridad +- Login/logout con JWT, sesión persistida en el navegador. +- Usuarios: alta/edición por el Administrador (nombre, email, rol, departamento, cargo, activo/inactivo). +- Auditoría central (`audit_log`): quién hizo qué, sobre qué entidad, cuándo. + +### 2. Organización +- Departamentos (con sub-área opcional vía `parent_id`, ej. TI → Desarrollo). +- Usuarios pertenecen a un departamento y tienen un cargo (texto libre). + +### 3. Proyectos +- Código autogenerado, nombre, descripción, objetivo, tipo, prioridad, estado, responsable, + miembros del equipo, fechas (inicio, fin estimado, fin real), % avance (calculado desde las + actividades), departamento solicitante y responsable, nivel de riesgo (bajo/medio/alto), notas. +- Estados: Planificado, En preparación, En ejecución, En pausa, Bloqueado, Completado, Cancelado. + +### 4. Actividades +- Código, nombre, descripción, responsable, prioridad, estado, fecha inicio, fecha límite, fecha + real de fin, % avance, marca de hito (`is_milestone`), horas estimadas, dependencias simples + ("depende de" otra actividad del mismo proyecto). +- Estados: Pendiente, En progreso, En revisión, Bloqueada, En espera, Completada, Cancelada. + +### 5. Subtareas +- Checklist liviano dentro de una actividad: nombre, responsable opcional, hecho/no hecho, fecha + límite opcional. Si una actividad tiene subtareas, su avance se calcula solo desde ellas. + +### 6. Dashboard +- KPIs: proyectos activos, proyectos atrasados, actividades pendientes/vencidas/para hoy, avance + promedio. Lista de actividades vencidas y de hoy del usuario. + +### 7. Calendario +- Vista mensual de actividades por fecha límite (incluye hitos marcados). + +### 8. Gantt +- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa + desde el gráfico en el MVP. + +### 9. Comentarios y adjuntos +- Hilo de comentarios por proyecto y por actividad. +- Adjuntar archivos (evidencias) a proyecto o actividad. + +### 10. Notificaciones +- In-app (campana con contador): asignación de actividad, actividad vencida/por vencer (job diario), + cambio de estado relevante. + +### 11. Configuración +- Catálogos editables por el Administrador: estados de proyecto, estados de actividad, prioridades, + tipos de proyecto, departamentos. Vienen con datos semilla — no requieren configuración inicial. +- Preferencias de interfaz: idioma es-EC/en-US, tema claro/oscuro, apariencia de botones. + +## Flujos clave + +1. Administrador crea departamentos y usuarios (con rol y departamento). +2. Jefe de TI o Coordinador crea un proyecto, asigna miembros, define fechas y prioridad. +3. Se crean actividades dentro del proyecto (con responsable, fechas, dependencias si aplica). +4. Cada responsable actualiza el avance de sus actividades/subtareas; el avance del proyecto se + recalcula solo. +5. El Jefe de TI entra al Dashboard y ve de un vistazo activos/atrasados/pendientes/avance. +6. Vencimientos se marcan automáticamente (🟢 en tiempo / 🟡 próximo a vencer / 🔴 vencido) y generan + notificación in-app al responsable. + +## Reglas funcionales importantes + +- El listado se ve primero; la creación se abre solo con el botón "Nuevo" (regla de UX del grupo). +- El % de avance de una actividad con subtareas es automático (no editable a mano); sin subtareas, + es manual (0-100). +- El % de avance de un proyecto es el promedio del avance de sus actividades (no editable a mano). +- Una actividad no puede marcarse "Completada" si depende de otra que no lo está (aviso, no bloqueo + duro en el MVP). +- Todo texto de interfaz sale de i18n (es-EC/en-US), nunca hardcodeado. + +## Fuera de alcance por ahora (fase 2+) + +- Riesgos e Incidencias como módulos propios (hoy: `risk_level`/notas en el proyecto). +- Solicitudes/requerimientos formales de otras áreas. +- Recursos (servidores/licencias/software), Proveedores, Presupuesto y costos. +- Registro de horas trabajadas detallado (bitácora diaria). +- Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada. +- Permisos configurables por módulo × acción desde la UI. +- Notificaciones por email/WhatsApp. +- Equipos de trabajo como entidad propia multi-proyecto (hoy: miembros por proyecto). + +## Estado para produccion + +- Completado: en desarrollo (MVP en construcción). +- Bloqueantes: ninguno conocido; deploy a producción pendiente de validación del MVP en local. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/VERSIONADO.md b/VERSIONADO.md new file mode 100644 index 0000000..ff7bf72 --- /dev/null +++ b/VERSIONADO.md @@ -0,0 +1,33 @@ +# Versionado - project.linkedcorp.net + +## Regla principal + +`.gitignore` ya copiado y ajustado desde `GITIGNORE_TEMPLATE.md`. Versionado `X.Y.Z`. + +- `X`: cambio fuerte o mayor (ruptura de compatibilidad, cambios grandes de arquitectura/BD/flujos) +- `Y`: cambio medio (módulo nuevo, funcionalidad nueva completa) +- `Z`: cambio bajo (correcciones, ajustes menores, hotfixes) + +## Si se debe versionar + +- Código fuente, `README.md`, `AGENTS.md`, `docs/`, `SPEC.md`, `API.md`, `BASE_DE_DATOS.md`, + `CONFIGURACION.md`, `.env.example`, `package.json` + lockfile, `database/migrations`, + `database/seeds`, `scripts/`. + +## No se debe versionar + +- `.env`, secretos/tokens/llaves, `node_modules/`, builds, logs, `storage/`, `backend/uploads/`, + archivos temporales, contexto local de despliegue. + +## Regla operativa + +- Antes de producción, revisar si corresponde subir versión. +- Toda versión nueva se refleja en `VERSION` y `CHANGELOG.md`. +- Usar `scripts/set-version.sh X.Y.Z` para actualizar todos los archivos de versión de una vez. + +## Script set-version.sh + +Ver `scripts/set-version.sh` en este proyecto (copiado de la plantilla del grupo, sin sección +Android porque este proyecto no tiene app móvil). + +Uso: `./scripts/set-version.sh 0.1.0` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..967050d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,15 @@ +PORT=4019 +NODE_ENV=development + +DB_HOST=192.168.222.20 +DB_PORT=3306 +DB_NAME=project_linkedcorp +DB_USER=project_linkedcorp +DB_PASSWORD= + +JWT_SECRET= +JWT_EXPIRES=8h + +CORS_ORIGIN=http://localhost:5196 + +STORAGE_ROOT=./storage diff --git a/backend/database/migrations/001_initial.sql b/backend/database/migrations/001_initial.sql new file mode 100644 index 0000000..06cb16a --- /dev/null +++ b/backend/database/migrations/001_initial.sql @@ -0,0 +1,196 @@ +-- project.linkedcorp.net — esquema inicial (MVP). Idempotente: seguro correr sobre BD existente. +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(30) NOT NULL UNIQUE, + name VARCHAR(80) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS departments ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + parent_id INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_departments_parent FOREIGN KEY (parent_id) REFERENCES departments(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + email VARCHAR(160) NOT NULL UNIQUE, + password_hash VARCHAR(120) NOT NULL, + role_id INT NOT NULL, + department_id INT NULL, + position VARCHAR(120) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_users_role FOREIGN KEY (role_id) REFERENCES roles(id), + CONSTRAINT fk_users_department FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS project_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS priorities ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(40) NOT NULL UNIQUE, + level INT NOT NULL, + color VARCHAR(20) NOT NULL DEFAULT 'accent' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS project_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(60) NOT NULL UNIQUE, + sort_order INT NOT NULL DEFAULT 0, + color VARCHAR(20) NOT NULL DEFAULT 'accent', + is_closed TINYINT(1) NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS activity_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(60) NOT NULL UNIQUE, + sort_order INT NOT NULL DEFAULT 0, + color VARCHAR(20) NOT NULL DEFAULT 'accent', + is_closed TINYINT(1) NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS projects ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(160) NOT NULL, + description TEXT NULL, + objective TEXT NULL, + project_type_id INT NULL, + priority_id INT NOT NULL, + status_id INT NOT NULL, + owner_user_id INT NOT NULL, + requesting_department_id INT NULL, + responsible_department_id INT NULL, + start_date DATE NULL, + estimated_end_date DATE NULL, + actual_end_date DATE NULL, + progress_pct DECIMAL(5,2) NOT NULL DEFAULT 0, + risk_level ENUM('low','medium','high') NOT NULL DEFAULT 'low', + notes TEXT NULL, + created_by INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_projects_type FOREIGN KEY (project_type_id) REFERENCES project_types(id) ON DELETE SET NULL, + CONSTRAINT fk_projects_priority FOREIGN KEY (priority_id) REFERENCES priorities(id), + CONSTRAINT fk_projects_status FOREIGN KEY (status_id) REFERENCES project_statuses(id), + CONSTRAINT fk_projects_owner FOREIGN KEY (owner_user_id) REFERENCES users(id), + CONSTRAINT fk_projects_req_dept FOREIGN KEY (requesting_department_id) REFERENCES departments(id) ON DELETE SET NULL, + CONSTRAINT fk_projects_resp_dept FOREIGN KEY (responsible_department_id) REFERENCES departments(id) ON DELETE SET NULL, + CONSTRAINT fk_projects_created_by FOREIGN KEY (created_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS project_members ( + project_id INT NOT NULL, + user_id INT NOT NULL, + role_in_project VARCHAR(60) NULL, + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, user_id), + CONSTRAINT fk_pm_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_pm_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS activities ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(160) NOT NULL, + description TEXT NULL, + responsible_user_id INT NULL, + priority_id INT NOT NULL, + status_id INT NOT NULL, + start_date DATE NULL, + due_date DATE NULL, + actual_end_date DATE NULL, + progress_pct DECIMAL(5,2) NOT NULL DEFAULT 0, + is_milestone TINYINT(1) NOT NULL DEFAULT 0, + estimated_hours DECIMAL(6,2) NULL, + created_by INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_activities_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT fk_activities_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_activities_priority FOREIGN KEY (priority_id) REFERENCES priorities(id), + CONSTRAINT fk_activities_status FOREIGN KEY (status_id) REFERENCES activity_statuses(id), + CONSTRAINT fk_activities_created_by FOREIGN KEY (created_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS activity_dependencies ( + activity_id INT NOT NULL, + depends_on_activity_id INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (activity_id, depends_on_activity_id), + CONSTRAINT fk_dep_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + CONSTRAINT fk_dep_depends_on FOREIGN KEY (depends_on_activity_id) REFERENCES activities(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS subtasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + activity_id INT NOT NULL, + name VARCHAR(160) NOT NULL, + responsible_user_id INT NULL, + is_done TINYINT(1) NOT NULL DEFAULT 0, + due_date DATE NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_subtasks_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + CONSTRAINT fk_subtasks_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS comments ( + id INT AUTO_INCREMENT PRIMARY KEY, + entity_type ENUM('project','activity') NOT NULL, + entity_id INT NOT NULL, + user_id INT NOT NULL, + message TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_comments_user FOREIGN KEY (user_id) REFERENCES users(id), + INDEX idx_comments_entity (entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS attachments ( + id INT AUTO_INCREMENT PRIMARY KEY, + entity_type ENUM('project','activity') NOT NULL, + entity_id INT NOT NULL, + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(120) NOT NULL, + size INT NOT NULL, + uploaded_by INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_attachments_user FOREIGN KEY (uploaded_by) REFERENCES users(id), + INDEX idx_attachments_entity (entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS notifications ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + type VARCHAR(60) NOT NULL, + message VARCHAR(255) NOT NULL, + entity_type ENUM('project','activity') NULL, + entity_id INT NULL, + is_read TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_notifications_user (user_id, is_read) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS audit_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + action VARCHAR(60) NOT NULL, + entity_type VARCHAR(40) NOT NULL, + entity_id INT NULL, + detail_json JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_audit_entity (entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..0229b05 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1248 @@ +{ + "name": "project-linkedcorp-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "project-linkedcorp-backend", + "version": "0.1.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^2.3.0", + "mysql2": "^3.11.0" + } + }, + "node_modules/@types/node": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mysql2": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz", + "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..b19a432 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,24 @@ +{ + "name": "project-linkedcorp-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/server.js", + "scripts": { + "dev": "node --watch src/server.js", + "start": "node src/server.js", + "migrate": "node src/scripts/migrate-db.js", + "seed": "node src/scripts/seed-db.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^2.3.0", + "mysql2": "^3.11.0" + } +} diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..7671621 --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,42 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import { env } from './config/env.js'; +import { notFoundHandler, errorHandler } from './middleware/errorHandler.js'; + +import authRoutes from './modules/auth/routes.js'; +import usersRoutes from './modules/users/routes.js'; +import departmentsRoutes from './modules/departments/routes.js'; +import catalogsRoutes from './modules/catalogs/routes.js'; +import projectsRoutes from './modules/projects/routes.js'; +import activitiesRoutes from './modules/activities/routes.js'; +import subtasksRoutes from './modules/subtasks/routes.js'; +import commentsRoutes from './modules/comments/routes.js'; +import attachmentsRoutes from './modules/attachments/routes.js'; +import notificationsRoutes from './modules/notifications/routes.js'; +import dashboardRoutes from './modules/dashboard/routes.js'; +import auditRoutes from './modules/audit/routes.js'; + +export const app = express(); + +app.use(helmet({ contentSecurityPolicy: false })); +app.use(cors({ origin: env.corsOrigin, credentials: true })); +app.use(express.json({ limit: '2mb' })); + +app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null })); + +app.use('/api/auth', authRoutes); +app.use('/api/users', usersRoutes); +app.use('/api/departments', departmentsRoutes); +app.use('/api/catalogs', catalogsRoutes); +app.use('/api/projects', projectsRoutes); +app.use('/api/activities', activitiesRoutes); +app.use('/api', subtasksRoutes); +app.use('/api/comments', commentsRoutes); +app.use('/api/attachments', attachmentsRoutes); +app.use('/api/notifications', notificationsRoutes); +app.use('/api/dashboard', dashboardRoutes); +app.use('/api/audit', auditRoutes); + +app.use(notFoundHandler); +app.use(errorHandler); diff --git a/backend/src/config/db.js b/backend/src/config/db.js new file mode 100644 index 0000000..aa66963 --- /dev/null +++ b/backend/src/config/db.js @@ -0,0 +1,13 @@ +import mysql from 'mysql2/promise'; +import { env } from './env.js'; + +export const pool = mysql.createPool({ + host: env.db.host, + port: env.db.port, + database: env.db.name, + user: env.db.user, + password: env.db.password, + waitForConnections: true, + connectionLimit: 10, + dateStrings: true, +}); diff --git a/backend/src/config/env.js b/backend/src/config/env.js new file mode 100644 index 0000000..3ab336d --- /dev/null +++ b/backend/src/config/env.js @@ -0,0 +1,32 @@ +import 'dotenv/config'; + +const required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'JWT_SECRET']; + +for (const key of required) { + if (!process.env[key]) { + if (process.env.NODE_ENV === 'production') { + throw new Error(`Falta la variable de entorno obligatoria: ${key}`); + } + console.warn(`[env] Falta ${key} — completa backend/.env`); + } +} + +if (process.env.NODE_ENV === 'production' && (process.env.JWT_SECRET || '').length < 32) { + throw new Error('JWT_SECRET debe tener al menos 32 caracteres en producción'); +} + +export const env = { + port: Number(process.env.PORT || 4019), + nodeEnv: process.env.NODE_ENV || 'development', + db: { + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT || 3306), + name: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + }, + jwtSecret: process.env.JWT_SECRET || 'dev-only-insecure-secret', + jwtExpires: process.env.JWT_EXPIRES || '8h', + corsOrigin: process.env.CORS_ORIGIN || 'http://localhost:5196', + storageRoot: process.env.STORAGE_ROOT || './storage', +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..a664dba --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,58 @@ +import jwt from 'jsonwebtoken'; +import { env } from '../config/env.js'; +import { pool } from '../config/db.js'; +import { fail } from '../utils/http.js'; + +export async function requireAuth(req, res, next) { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return fail(res, 401, 'No autenticado'); + + let payload; + try { + payload = jwt.verify(token, env.jwtSecret); + } catch { + return fail(res, 401, 'Token invalido o expirado'); + } + + // Revalidar en cada request que la cuenta siga activa (un JWT firmado no basta). + const [rows] = await pool.query( + `SELECT u.id, u.name, u.email, u.is_active, u.department_id, r.code AS role_code + FROM users u JOIN roles r ON r.id = u.role_id + WHERE u.id = ? LIMIT 1`, + [payload.sub] + ); + const user = rows[0]; + if (!user || !user.is_active) return fail(res, 401, 'Cuenta inactiva o inexistente'); + + req.currentUser = { + id: user.id, + name: user.name, + email: user.email, + roleCode: user.role_code, + departmentId: user.department_id, + }; + next(); +} + +export function requireRole(...roleCodes) { + return (req, res, next) => { + if (!req.currentUser) return fail(res, 401, 'No autenticado'); + if (!roleCodes.includes(req.currentUser.roleCode)) { + return fail(res, 403, 'No tienes permisos para esta accion'); + } + next(); + }; +} + +export const ROLES = { + ADMIN: 'admin', + IT_MANAGER: 'it_manager', + PROJECT_LEAD: 'project_lead', + COLLABORATOR: 'collaborator', + VIEWER: 'viewer', +}; + +export const CAN_MANAGE_CATALOGS = [ROLES.ADMIN]; +export const CAN_MANAGE_USERS = [ROLES.ADMIN]; +export const CAN_WRITE = [ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR]; diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..04937d5 --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -0,0 +1,18 @@ +import { HttpError } from '../utils/http.js'; + +export function notFoundHandler(req, res) { + res.status(404).json({ success: false, data: null, error: 'Ruta no encontrada' }); +} + +export function errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars + if (err instanceof HttpError) { + return res.status(err.status).json({ success: false, data: null, error: err.message }); + } + console.error('[error]', err); + const isProd = process.env.NODE_ENV === 'production'; + res.status(500).json({ + success: false, + data: null, + error: isProd ? 'Error interno' : err.message, + }); +} diff --git a/backend/src/middleware/upload.js b/backend/src/middleware/upload.js new file mode 100644 index 0000000..bc0a4bd --- /dev/null +++ b/backend/src/middleware/upload.js @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import multer from 'multer'; +import { env } from '../config/env.js'; + +const ALLOWED_MIME = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/webp', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/plain', + 'application/zip', +]); + +const uploadsDir = path.join(env.storageRoot, 'attachments'); +fs.mkdirSync(uploadsDir, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, uploadsDir), + // Nombre generado por el servidor: evita path traversal y colisiones. + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).slice(0, 10); + cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`); + }, +}); + +export const upload = multer({ + storage, + limits: { fileSize: 15 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!ALLOWED_MIME.has(file.mimetype)) { + return cb(new Error('Tipo de archivo no permitido')); + } + cb(null, true); + }, +}); + +export { uploadsDir }; diff --git a/backend/src/modules/activities/controller.js b/backend/src/modules/activities/controller.js new file mode 100644 index 0000000..6296415 --- /dev/null +++ b/backend/src/modules/activities/controller.js @@ -0,0 +1,28 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getActivitiesByProject = asyncHandler(async (req, res) => { + ok(res, await service.listByProject(Number(req.params.projectId))); +}); + +export const postActivity = asyncHandler(async (req, res) => { + ok(res, await service.createActivity(Number(req.params.projectId), req.body, req.currentUser), 201); +}); + +export const getActivity = asyncHandler(async (req, res) => { + ok(res, await service.getActivity(Number(req.params.id))); +}); + +export const putActivity = asyncHandler(async (req, res) => { + ok(res, await service.updateActivity(Number(req.params.id), req.body, req.currentUser)); +}); + +export const postDependency = asyncHandler(async (req, res) => { + const deps = await service.addDependency(Number(req.params.id), Number(req.body.dependsOnActivityId), req.currentUser); + ok(res, deps, 201); +}); + +export const deleteDependency = asyncHandler(async (req, res) => { + const deps = await service.removeDependency(Number(req.params.id), Number(req.params.dependsOnId), req.currentUser); + ok(res, deps); +}); diff --git a/backend/src/modules/activities/repository.js b/backend/src/modules/activities/repository.js new file mode 100644 index 0000000..a5675b4 --- /dev/null +++ b/backend/src/modules/activities/repository.js @@ -0,0 +1,103 @@ +import { pool } from '../../config/db.js'; + +const LIST_SELECT = ` + SELECT a.id, a.project_id, a.code, a.name, a.description, a.responsible_user_id, u.name AS responsible_name, + a.priority_id, pr.name AS priority_name, pr.color AS priority_color, + 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.estimated_hours, a.created_by, a.created_at, a.updated_at + FROM activities a + JOIN priorities pr ON pr.id = a.priority_id + JOIN activity_statuses st ON st.id = a.status_id + LEFT JOIN users u ON u.id = a.responsible_user_id +`; + +export async function findByProject(projectId) { + const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? ORDER BY a.start_date, a.id`, [projectId]); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query(`${LIST_SELECT} WHERE a.id = ?`, [id]); + return rows[0] || null; +} + +export async function create(projectId, data) { + const [result] = await pool.query( + `INSERT INTO activities + (project_id, code, name, description, responsible_user_id, priority_id, status_id, + start_date, due_date, is_milestone, estimated_hours, created_by) + VALUES (?, 'PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + projectId, data.name, data.description || null, data.responsibleUserId || null, + data.priorityId, data.statusId, data.startDate || null, data.dueDate || null, + data.isMilestone ? 1 : 0, data.estimatedHours || null, data.createdBy, + ] + ); + const id = result.insertId; + const code = `ACT-${String(id).padStart(4, '0')}`; + await pool.query('UPDATE activities SET code = ? WHERE id = ?', [code, id]); + return findById(id); +} + +export async function update(id, data) { + await pool.query( + `UPDATE activities SET + name = ?, description = ?, responsible_user_id = ?, priority_id = ?, status_id = ?, + start_date = ?, due_date = ?, actual_end_date = ?, is_milestone = ?, estimated_hours = ? + WHERE id = ?`, + [ + data.name, data.description || null, data.responsibleUserId || null, data.priorityId, data.statusId, + data.startDate || null, data.dueDate || null, data.actualEndDate || null, + data.isMilestone ? 1 : 0, data.estimatedHours || null, id, + ] + ); + return findById(id); +} + +export async function updateProgress(id, progressPct) { + await pool.query('UPDATE activities SET progress_pct = ? WHERE id = ?', [progressPct, id]); +} + +export async function countSubtasks(id) { + const [rows] = await pool.query('SELECT COUNT(*) AS c FROM subtasks WHERE activity_id = ?', [id]); + return rows[0].c; +} + +export async function listDependencies(id) { + const [rows] = await pool.query( + `SELECT d.depends_on_activity_id AS id, a.code, a.name, a.status_id, st.is_closed AS status_is_closed + FROM activity_dependencies d + JOIN activities a ON a.id = d.depends_on_activity_id + JOIN activity_statuses st ON st.id = a.status_id + WHERE d.activity_id = ?`, + [id] + ); + return rows; +} + +export async function addDependency(activityId, dependsOnActivityId) { + await pool.query( + 'INSERT IGNORE INTO activity_dependencies (activity_id, depends_on_activity_id) VALUES (?, ?)', + [activityId, dependsOnActivityId] + ); + return listDependencies(activityId); +} + +export async function removeDependency(activityId, dependsOnActivityId) { + await pool.query('DELETE FROM activity_dependencies WHERE activity_id = ? AND depends_on_activity_id = ?', [ + activityId, dependsOnActivityId, + ]); + return listDependencies(activityId); +} + +export async function findDueOrOverdue(userId, { onlyMine = false } = {}) { + const clauses = ["st.is_closed = 0", "a.due_date IS NOT NULL"]; + const params = []; + if (onlyMine) { clauses.push('a.responsible_user_id = ?'); params.push(userId); } + const [rows] = await pool.query( + `${LIST_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY a.due_date`, + params + ); + return rows; +} diff --git a/backend/src/modules/activities/routes.js b/backend/src/modules/activities/routes.js new file mode 100644 index 0000000..05852d7 --- /dev/null +++ b/backend/src/modules/activities/routes.js @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getActivity, putActivity, postDependency, deleteDependency } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/:id', getActivity); +router.put('/:id', putActivity); +router.post('/:id/dependencies', postDependency); +router.delete('/:id/dependencies/:dependsOnId', deleteDependency); + +export default router; diff --git a/backend/src/modules/activities/service.js b/backend/src/modules/activities/service.js new file mode 100644 index 0000000..2ec49d8 --- /dev/null +++ b/backend/src/modules/activities/service.js @@ -0,0 +1,130 @@ +import * as repo from './repository.js'; +import * as projectsRepo from '../projects/repository.js'; +import { recalcProjectProgress, canWriteProject } from '../projects/service.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { ROLES } from '../../middleware/auth.js'; +import { createNotification } from '../notifications/service.js'; + +async function getProjectOr404(projectId) { + const project = await projectsRepo.findById(projectId); + if (!project) throw new HttpError(404, 'Proyecto no encontrado'); + return project; +} + +// admin/it_manager siempre; project_lead si tiene acceso al proyecto; collaborator solo si es +// el responsable de la actividad (no puede crear actividades nuevas ni reasignarlas a otros). +export async function canWriteActivity(user, project, activity) { + if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true; + if (user.roleCode === ROLES.PROJECT_LEAD) return canWriteProject(user, project); + if (user.roleCode === ROLES.COLLABORATOR && activity) return activity.responsible_user_id === user.id; + return false; +} + +export const listByProject = (projectId) => repo.findByProject(projectId); + +export async function getActivity(id) { + const activity = await repo.findById(id); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const dependencies = await repo.listDependencies(id); + return { ...activity, dependencies }; +} + +export async function createActivity(projectId, payload, currentUser) { + const project = await getProjectOr404(projectId); + if (!(await canWriteProject(currentUser, project))) { + throw new HttpError(403, 'No tienes permisos para crear actividades en este proyecto'); + } + if (!payload.name || !payload.priorityId || !payload.statusId) { + throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); + } + const activity = await repo.create(projectId, { ...payload, createdBy: currentUser.id }); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'activity', entityId: activity.id }); + + if (activity.responsible_user_id && activity.responsible_user_id !== currentUser.id) { + await createNotification({ + userId: activity.responsible_user_id, + type: 'activity_assigned', + message: `Te asignaron la actividad "${activity.name}"`, + entityType: 'activity', + entityId: activity.id, + }); + } + await recalcProjectProgress(projectId); + return activity; +} + +export async function updateActivity(id, payload, currentUser) { + const activity = await repo.findById(id); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const project = await getProjectOr404(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) { + throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + } + if (!payload.name || !payload.priorityId || !payload.statusId) { + throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); + } + + // Colaborador no puede reasignar la actividad a otra persona ni cambiar avance manual si hay subtareas. + const nextResponsible = currentUser.roleCode === ROLES.COLLABORATOR + ? activity.responsible_user_id + : (payload.responsibleUserId ?? activity.responsible_user_id); + + const subtaskCount = await repo.countSubtasks(id); + const updated = await repo.update(id, { ...payload, responsibleUserId: nextResponsible }); + + if (subtaskCount === 0 && payload.progressPct !== undefined) { + const pct = Math.max(0, Math.min(100, Number(payload.progressPct))); + await repo.updateProgress(id, pct); + updated.progress_pct = pct; + } + + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'activity', entityId: id }); + + if (nextResponsible && nextResponsible !== activity.responsible_user_id && nextResponsible !== currentUser.id) { + await createNotification({ + userId: nextResponsible, + type: 'activity_assigned', + message: `Te asignaron la actividad "${updated.name}"`, + entityType: 'activity', + entityId: id, + }); + } + + await recalcProjectProgress(activity.project_id); + return repo.findById(id); +} + +export async function addDependency(activityId, dependsOnActivityId, currentUser) { + if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma'); + const activity = await repo.findById(activityId); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const dependsOn = await repo.findById(dependsOnActivityId); + if (!dependsOn || dependsOn.project_id !== activity.project_id) { + throw new HttpError(400, 'La dependencia debe ser una actividad del mismo proyecto'); + } + const project = await getProjectOr404(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + const deps = await repo.addDependency(activityId, dependsOnActivityId); + await logAudit({ userId: currentUser.id, action: 'add_dependency', entityType: 'activity', entityId: activityId, detail: { dependsOnActivityId } }); + return deps; +} + +export async function removeDependency(activityId, dependsOnActivityId, currentUser) { + const activity = await repo.findById(activityId); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const project = await getProjectOr404(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + const deps = await repo.removeDependency(activityId, dependsOnActivityId); + await logAudit({ userId: currentUser.id, action: 'remove_dependency', entityType: 'activity', entityId: activityId, detail: { dependsOnActivityId } }); + return deps; +} + +// Recalcula el avance de una actividad a partir de sus subtareas (si tiene) y propaga al proyecto. +export async function recalcFromSubtasks(activityId, doneCount, totalCount) { + if (totalCount === 0) return; + const pct = Math.round((doneCount / totalCount) * 10000) / 100; + await repo.updateProgress(activityId, pct); + const activity = await repo.findById(activityId); + await recalcProjectProgress(activity.project_id); +} diff --git a/backend/src/modules/attachments/controller.js b/backend/src/modules/attachments/controller.js new file mode 100644 index 0000000..41c5e09 --- /dev/null +++ b/backend/src/modules/attachments/controller.js @@ -0,0 +1,51 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { ok, fail, asyncHandler, HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { ROLES } from '../../middleware/auth.js'; +import { uploadsDir } from '../../middleware/upload.js'; +import * as repo from './repository.js'; + +const VALID_ENTITY_TYPES = new Set(['project', 'activity']); + +export const getAttachments = asyncHandler(async (req, res) => { + const { entityType, entityId } = req.query; + if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios'); + ok(res, await repo.findByEntity(entityType, Number(entityId))); +}); + +export const postAttachment = asyncHandler(async (req, res) => { + const { entityType, entityId } = req.body || {}; + if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) throw new HttpError(400, 'entityType y entityId son obligatorios'); + if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura'); + if (!req.file) throw new HttpError(400, 'Archivo obligatorio'); + + const attachment = await repo.create({ + entityType, entityId: Number(entityId), + filename: req.file.filename, originalName: req.file.originalname, + mimeType: req.file.mimetype, size: req.file.size, uploadedBy: req.currentUser.id, + }); + await logAudit({ userId: req.currentUser.id, action: 'upload', entityType, entityId: Number(entityId), detail: { attachmentId: attachment.id } }); + ok(res, attachment, 201); +}); + +export const downloadAttachment = asyncHandler(async (req, res) => { + const attachment = await repo.findById(Number(req.params.id)); + if (!attachment) throw new HttpError(404, 'Adjunto no encontrado'); + const filePath = path.join(uploadsDir, attachment.filename); + if (!fs.existsSync(filePath)) throw new HttpError(404, 'Archivo no encontrado en el servidor'); + res.download(filePath, attachment.original_name); +}); + +export const deleteAttachment = asyncHandler(async (req, res) => { + const attachment = await repo.findById(Number(req.params.id)); + if (!attachment) throw new HttpError(404, 'Adjunto no encontrado'); + const canDelete = [ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id; + if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto'); + + await repo.remove(attachment.id); + const filePath = path.join(uploadsDir, attachment.filename); + fs.promises.unlink(filePath).catch(() => {}); + await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: attachment.entity_type, entityId: attachment.entity_id, detail: { attachmentId: attachment.id } }); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/attachments/repository.js b/backend/src/modules/attachments/repository.js new file mode 100644 index 0000000..5348d31 --- /dev/null +++ b/backend/src/modules/attachments/repository.js @@ -0,0 +1,30 @@ +import { pool } from '../../config/db.js'; + +export async function findByEntity(entityType, entityId) { + const [rows] = await pool.query( + `SELECT a.id, a.entity_type, a.entity_id, a.filename, a.original_name, a.mime_type, a.size, + a.uploaded_by, u.name AS uploaded_by_name, a.created_at + FROM attachments a JOIN users u ON u.id = a.uploaded_by + WHERE a.entity_type = ? AND a.entity_id = ? ORDER BY a.created_at DESC`, + [entityType, entityId] + ); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query('SELECT * FROM attachments WHERE id = ?', [id]); + return rows[0] || null; +} + +export async function create({ entityType, entityId, filename, originalName, mimeType, size, uploadedBy }) { + const [result] = await pool.query( + `INSERT INTO attachments (entity_type, entity_id, filename, original_name, mime_type, size, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [entityType, entityId, filename, originalName, mimeType, size, uploadedBy] + ); + return findById(result.insertId); +} + +export async function remove(id) { + await pool.query('DELETE FROM attachments WHERE id = ?', [id]); +} diff --git a/backend/src/modules/attachments/routes.js b/backend/src/modules/attachments/routes.js new file mode 100644 index 0000000..2b103db --- /dev/null +++ b/backend/src/modules/attachments/routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { upload } from '../../middleware/upload.js'; +import { getAttachments, postAttachment, downloadAttachment, deleteAttachment } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getAttachments); +router.post('/', upload.single('file'), postAttachment); +router.get('/:id/download', downloadAttachment); +router.delete('/:id', deleteAttachment); + +export default router; diff --git a/backend/src/modules/audit/controller.js b/backend/src/modules/audit/controller.js new file mode 100644 index 0000000..a158942 --- /dev/null +++ b/backend/src/modules/audit/controller.js @@ -0,0 +1,7 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as repo from './repository.js'; + +export const getAudit = asyncHandler(async (req, res) => { + const { entityType, entityId } = req.query; + ok(res, await repo.findAll({ entityType, entityId: entityId ? Number(entityId) : undefined })); +}); diff --git a/backend/src/modules/audit/repository.js b/backend/src/modules/audit/repository.js new file mode 100644 index 0000000..593d749 --- /dev/null +++ b/backend/src/modules/audit/repository.js @@ -0,0 +1,16 @@ +import { pool } from '../../config/db.js'; + +export async function findAll({ entityType, entityId } = {}) { + const clauses = []; + const params = []; + if (entityType) { clauses.push('a.entity_type = ?'); params.push(entityType); } + if (entityId) { clauses.push('a.entity_id = ?'); params.push(entityId); } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [rows] = await pool.query( + `SELECT a.id, a.user_id, u.name AS user_name, a.action, a.entity_type, a.entity_id, a.detail_json, a.created_at + FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + ${where} ORDER BY a.created_at DESC LIMIT 200`, + params + ); + return rows; +} diff --git a/backend/src/modules/audit/routes.js b/backend/src/modules/audit/routes.js new file mode 100644 index 0000000..754eda6 --- /dev/null +++ b/backend/src/modules/audit/routes.js @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js'; +import { getAudit } from './controller.js'; + +const router = Router(); + +router.use(requireAuth, requireRole(ROLES.ADMIN, ROLES.IT_MANAGER)); +router.get('/', getAudit); + +export default router; diff --git a/backend/src/modules/auth/controller.js b/backend/src/modules/auth/controller.js new file mode 100644 index 0000000..aa485ca --- /dev/null +++ b/backend/src/modules/auth/controller.js @@ -0,0 +1,18 @@ +import { ok, fail, asyncHandler } from '../../utils/http.js'; +import * as authService from './service.js'; + +export const postLogin = asyncHandler(async (req, res) => { + const { email, password } = req.body || {}; + if (!email || !password) return fail(res, 400, 'Email y clave son obligatorios'); + const result = await authService.login(email, password); + ok(res, result); +}); + +export const postLogout = asyncHandler(async (req, res) => { + // Stateless JWT: el cliente descarta el token. Se deja el endpoint para uso futuro (blacklist). + ok(res, { loggedOut: true }); +}); + +export const getMe = asyncHandler(async (req, res) => { + ok(res, req.currentUser); +}); diff --git a/backend/src/modules/auth/routes.js b/backend/src/modules/auth/routes.js new file mode 100644 index 0000000..161de59 --- /dev/null +++ b/backend/src/modules/auth/routes.js @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import { requireAuth } from '../../middleware/auth.js'; +import { postLogin, postLogout, getMe } from './controller.js'; + +const router = Router(); + +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 20, + standardHeaders: true, + legacyHeaders: false, +}); + +router.post('/login', loginLimiter, postLogin); +router.post('/logout', requireAuth, postLogout); +router.get('/me', requireAuth, getMe); + +export default router; diff --git a/backend/src/modules/auth/service.js b/backend/src/modules/auth/service.js new file mode 100644 index 0000000..20d547f --- /dev/null +++ b/backend/src/modules/auth/service.js @@ -0,0 +1,36 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { pool } from '../../config/db.js'; +import { env } from '../../config/env.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; + +export async function login(email, password) { + const [rows] = await pool.query( + `SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.department_id, r.code AS role_code, r.name AS role_name + FROM users u JOIN roles r ON r.id = u.role_id + WHERE u.email = ? LIMIT 1`, + [email] + ); + const user = rows[0]; + if (!user || !user.is_active) throw new HttpError(401, 'Credenciales invalidas'); + + const validPassword = await bcrypt.compare(password, user.password_hash); + if (!validPassword) throw new HttpError(401, 'Credenciales invalidas'); + + const token = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires }); + + await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id }); + + return { + token, + user: { + id: user.id, + name: user.name, + email: user.email, + roleCode: user.role_code, + roleName: user.role_name, + departmentId: user.department_id, + }, + }; +} diff --git a/backend/src/modules/catalogs/controller.js b/backend/src/modules/catalogs/controller.js new file mode 100644 index 0000000..b4d2cbf --- /dev/null +++ b/backend/src/modules/catalogs/controller.js @@ -0,0 +1,43 @@ +import { ok, fail, asyncHandler } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { CATALOGS } from './repository.js'; +import * as repo from './repository.js'; + +function validKey(req, res) { + const key = req.params.catalog; + if (!CATALOGS[key]) { + fail(res, 404, 'Catalogo no encontrado'); + return null; + } + return key; +} + +export const getCatalog = asyncHandler(async (req, res) => { + const key = validKey(req, res); + if (!key) return; + ok(res, await repo.findAll(key)); +}); + +export const postCatalog = asyncHandler(async (req, res) => { + const key = validKey(req, res); + if (!key) return; + const item = await repo.create(key, req.body); + await logAudit({ userId: req.currentUser.id, action: 'create', entityType: `catalog:${key}`, entityId: item.id }); + ok(res, item, 201); +}); + +export const putCatalog = asyncHandler(async (req, res) => { + const key = validKey(req, res); + if (!key) return; + const item = await repo.update(key, Number(req.params.id), req.body); + await logAudit({ userId: req.currentUser.id, action: 'update', entityType: `catalog:${key}`, entityId: item.id }); + ok(res, item); +}); + +export const deleteCatalog = asyncHandler(async (req, res) => { + const key = validKey(req, res); + if (!key) return; + await repo.remove(key, Number(req.params.id)); + await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: `catalog:${key}`, entityId: Number(req.params.id) }); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/catalogs/repository.js b/backend/src/modules/catalogs/repository.js new file mode 100644 index 0000000..c355e0f --- /dev/null +++ b/backend/src/modules/catalogs/repository.js @@ -0,0 +1,47 @@ +import { pool } from '../../config/db.js'; + +// Whitelist de catalogos editables — nunca aceptar el nombre de tabla desde el cliente. +export const CATALOGS = { + 'project-types': { table: 'project_types', fields: ['name'], order: 'name' }, + priorities: { table: 'priorities', fields: ['name', 'level', 'color'], order: 'level' }, + 'project-statuses': { + table: 'project_statuses', + fields: ['name', 'sort_order', 'color', 'is_closed'], + order: 'sort_order', + }, + 'activity-statuses': { + table: 'activity_statuses', + fields: ['name', 'sort_order', 'color', 'is_closed'], + order: 'sort_order', + }, +}; + +export async function findAll(catalogKey) { + const cfg = CATALOGS[catalogKey]; + const [rows] = await pool.query(`SELECT * FROM ${cfg.table} ORDER BY ${cfg.order}`); + return rows; +} + +export async function create(catalogKey, payload) { + const cfg = CATALOGS[catalogKey]; + const cols = cfg.fields.filter((f) => payload[f] !== undefined); + const placeholders = cols.map(() => '?').join(', '); + const [result] = await pool.query( + `INSERT INTO ${cfg.table} (${cols.join(', ')}) VALUES (${placeholders})`, + cols.map((c) => payload[c]) + ); + return { id: result.insertId, ...payload }; +} + +export async function update(catalogKey, id, payload) { + const cfg = CATALOGS[catalogKey]; + const cols = cfg.fields.filter((f) => payload[f] !== undefined); + const setClause = cols.map((c) => `${c} = ?`).join(', '); + await pool.query(`UPDATE ${cfg.table} SET ${setClause} WHERE id = ?`, [...cols.map((c) => payload[c]), id]); + return { id, ...payload }; +} + +export async function remove(catalogKey, id) { + const cfg = CATALOGS[catalogKey]; + await pool.query(`DELETE FROM ${cfg.table} WHERE id = ?`, [id]); +} diff --git a/backend/src/modules/catalogs/routes.js b/backend/src/modules/catalogs/routes.js new file mode 100644 index 0000000..de359bf --- /dev/null +++ b/backend/src/modules/catalogs/routes.js @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, CAN_MANAGE_CATALOGS } from '../../middleware/auth.js'; +import { getCatalog, postCatalog, putCatalog, deleteCatalog } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/:catalog', getCatalog); +router.post('/:catalog', requireRole(...CAN_MANAGE_CATALOGS), postCatalog); +router.put('/:catalog/:id', requireRole(...CAN_MANAGE_CATALOGS), putCatalog); +router.delete('/:catalog/:id', requireRole(...CAN_MANAGE_CATALOGS), deleteCatalog); + +export default router; diff --git a/backend/src/modules/comments/controller.js b/backend/src/modules/comments/controller.js new file mode 100644 index 0000000..b1ca3a4 --- /dev/null +++ b/backend/src/modules/comments/controller.js @@ -0,0 +1,24 @@ +import { ok, fail, asyncHandler } from '../../utils/http.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { ROLES } from '../../middleware/auth.js'; +import * as repo from './repository.js'; + +const VALID_ENTITY_TYPES = new Set(['project', 'activity']); + +export const getComments = asyncHandler(async (req, res) => { + const { entityType, entityId } = req.query; + if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) return fail(res, 400, 'entityType y entityId son obligatorios'); + ok(res, await repo.findByEntity(entityType, Number(entityId))); +}); + +export const postComment = asyncHandler(async (req, res) => { + const { entityType, entityId, message } = req.body || {}; + if (!VALID_ENTITY_TYPES.has(entityType) || !entityId || !message) { + throw new HttpError(400, 'entityType, entityId y message son obligatorios'); + } + if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura'); + const comment = await repo.create({ entityType, entityId, userId: req.currentUser.id, message }); + await logAudit({ userId: req.currentUser.id, action: 'comment', entityType, entityId, detail: { commentId: comment.id } }); + ok(res, comment, 201); +}); diff --git a/backend/src/modules/comments/repository.js b/backend/src/modules/comments/repository.js new file mode 100644 index 0000000..8db1a22 --- /dev/null +++ b/backend/src/modules/comments/repository.js @@ -0,0 +1,24 @@ +import { pool } from '../../config/db.js'; + +export async function findByEntity(entityType, entityId) { + const [rows] = await pool.query( + `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at + FROM comments c JOIN users u ON u.id = c.user_id + WHERE c.entity_type = ? AND c.entity_id = ? ORDER BY c.created_at`, + [entityType, entityId] + ); + return rows; +} + +export async function create({ entityType, entityId, userId, message }) { + const [result] = await pool.query( + 'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)', + [entityType, entityId, userId, message] + ); + const [rows] = await pool.query( + `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at + FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`, + [result.insertId] + ); + return rows[0]; +} diff --git a/backend/src/modules/comments/routes.js b/backend/src/modules/comments/routes.js new file mode 100644 index 0000000..456eff2 --- /dev/null +++ b/backend/src/modules/comments/routes.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getComments, postComment } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getComments); +router.post('/', postComment); + +export default router; diff --git a/backend/src/modules/dashboard/controller.js b/backend/src/modules/dashboard/controller.js new file mode 100644 index 0000000..f24dceb --- /dev/null +++ b/backend/src/modules/dashboard/controller.js @@ -0,0 +1,35 @@ +import { ok, fail, asyncHandler } from '../../utils/http.js'; +import * as repo from './repository.js'; +import { listByProject } from '../activities/service.js'; +import { dueStatus } from '../../utils/dates.js'; + +export const getSummary = asyncHandler(async (req, res) => { + const [kpis, myActivities, byStatus] = await Promise.all([ + repo.summary(), + repo.myPendingActivities(req.currentUser.id), + repo.projectsByStatus(), + ]); + ok(res, { + kpis, + myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })), + projectsByStatus: byStatus, + }); +}); + +export const getCalendar = asyncHandler(async (req, res) => { + const month = req.query.month; // formato YYYY-MM + if (!month || !/^\d{4}-\d{2}$/.test(month)) return fail(res, 400, 'Parametro month invalido (YYYY-MM)'); + const start = `${month}-01`; + const [year, m] = month.split('-').map(Number); + const end = new Date(year, m, 0).toISOString().slice(0, 10); + const items = await repo.calendarActivities(start, end); + ok(res, items.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date, a.status_is_closed) }))); +}); + +export const getGantt = asyncHandler(async (req, res) => { + const activities = await listByProject(Number(req.params.id)); + ok(res, activities.map((a) => ({ + id: a.id, code: a.code, name: a.name, startDate: a.start_date, dueDate: a.due_date, + progressPct: a.progress_pct, isMilestone: !!a.is_milestone, responsibleName: a.responsible_name, + }))); +}); diff --git a/backend/src/modules/dashboard/repository.js b/backend/src/modules/dashboard/repository.js new file mode 100644 index 0000000..9af92e3 --- /dev/null +++ b/backend/src/modules/dashboard/repository.js @@ -0,0 +1,72 @@ +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` + ); + 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()` + ); + 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 + 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` + ); + 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()` + ); + 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()` + ); + 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` + ); + + return { + activeProjects: activeProjects.c, + overdueProjects: overdueProjects.c, + dueSoonProjects: dueSoonProjects.c, + pendingActivities: pendingActivities.c, + overdueActivities: overdueActivities.c, + todayActivities: todayActivities.c, + averageProgress: avgProgress.avg_pct === null ? 0 : Math.round(Number(avgProgress.avg_pct) * 100) / 100, + }; +} + +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 = ? + ORDER BY (a.due_date IS NULL), a.due_date LIMIT 20`, + [userId] + ); + return rows; +} + +export async function calendarActivities(monthStart, monthEnd) { + const [rows] = await pool.query( + `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 ? + ORDER BY a.due_date`, + [monthStart, monthEnd] + ); + return rows; +} + +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 + GROUP BY ps.id ORDER BY ps.sort_order` + ); + return rows; +} diff --git a/backend/src/modules/dashboard/routes.js b/backend/src/modules/dashboard/routes.js new file mode 100644 index 0000000..7ab123d --- /dev/null +++ b/backend/src/modules/dashboard/routes.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getSummary, getCalendar } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/summary', getSummary); +router.get('/calendar', getCalendar); + +export default router; diff --git a/backend/src/modules/departments/controller.js b/backend/src/modules/departments/controller.js new file mode 100644 index 0000000..6402996 --- /dev/null +++ b/backend/src/modules/departments/controller.js @@ -0,0 +1,17 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getDepartments = asyncHandler(async (req, res) => ok(res, await service.listTree())); + +export const postDepartment = asyncHandler(async (req, res) => { + ok(res, await service.createDepartment(req.body, req.currentUser.id), 201); +}); + +export const putDepartment = asyncHandler(async (req, res) => { + ok(res, await service.updateDepartment(Number(req.params.id), req.body, req.currentUser.id)); +}); + +export const deleteDepartment = asyncHandler(async (req, res) => { + await service.deleteDepartment(Number(req.params.id), req.currentUser.id); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/departments/repository.js b/backend/src/modules/departments/repository.js new file mode 100644 index 0000000..00d940b --- /dev/null +++ b/backend/src/modules/departments/repository.js @@ -0,0 +1,33 @@ +import { pool } from '../../config/db.js'; + +export async function findAllFlat() { + const [rows] = await pool.query('SELECT id, name, parent_id FROM departments ORDER BY name'); + return rows; +} + +export async function create({ name, parentId }) { + const [result] = await pool.query('INSERT INTO departments (name, parent_id) VALUES (?, ?)', [ + name, + parentId || null, + ]); + return { id: result.insertId, name, parent_id: parentId || null }; +} + +export async function update(id, { name, parentId }) { + await pool.query('UPDATE departments SET name = ?, parent_id = ? WHERE id = ?', [name, parentId || null, id]); + return { id, name, parent_id: parentId || null }; +} + +export async function remove(id) { + await pool.query('DELETE FROM departments WHERE id = ?', [id]); +} + +export async function hasChildren(id) { + const [rows] = await pool.query('SELECT COUNT(*) AS c FROM departments WHERE parent_id = ?', [id]); + return rows[0].c > 0; +} + +export async function hasUsers(id) { + const [rows] = await pool.query('SELECT COUNT(*) AS c FROM users WHERE department_id = ?', [id]); + return rows[0].c > 0; +} diff --git a/backend/src/modules/departments/routes.js b/backend/src/modules/departments/routes.js new file mode 100644 index 0000000..7d864e6 --- /dev/null +++ b/backend/src/modules/departments/routes.js @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js'; +import { getDepartments, postDepartment, putDepartment, deleteDepartment } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getDepartments); +router.post('/', requireRole(...CAN_MANAGE_USERS), postDepartment); +router.put('/:id', requireRole(...CAN_MANAGE_USERS), putDepartment); +router.delete('/:id', requireRole(...CAN_MANAGE_USERS), deleteDepartment); + +export default router; diff --git a/backend/src/modules/departments/service.js b/backend/src/modules/departments/service.js new file mode 100644 index 0000000..d3bf17e --- /dev/null +++ b/backend/src/modules/departments/service.js @@ -0,0 +1,43 @@ +import * as repo from './repository.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; + +function toTree(flat) { + const byId = new Map(flat.map((d) => [d.id, { ...d, children: [] }])); + const roots = []; + for (const dept of byId.values()) { + if (dept.parent_id && byId.has(dept.parent_id)) { + byId.get(dept.parent_id).children.push(dept); + } else { + roots.push(dept); + } + } + return roots; +} + +export async function listTree() { + const flat = await repo.findAllFlat(); + return toTree(flat); +} + +export async function createDepartment(payload, actorId) { + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + const dept = await repo.create(payload); + await logAudit({ userId: actorId, action: 'create', entityType: 'department', entityId: dept.id }); + return dept; +} + +export async function updateDepartment(id, payload, actorId) { + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + if (Number(payload.parentId) === Number(id)) throw new HttpError(400, 'Un departamento no puede ser su propio padre'); + const dept = await repo.update(id, payload); + await logAudit({ userId: actorId, action: 'update', entityType: 'department', entityId: id }); + return dept; +} + +export async function deleteDepartment(id, actorId) { + if (await repo.hasChildren(id)) throw new HttpError(400, 'Tiene sub-departamentos; reasignalos antes de eliminar'); + if (await repo.hasUsers(id)) throw new HttpError(400, 'Tiene usuarios asignados; reasignalos antes de eliminar'); + await repo.remove(id); + await logAudit({ userId: actorId, action: 'delete', entityType: 'department', entityId: id }); +} diff --git a/backend/src/modules/notifications/controller.js b/backend/src/modules/notifications/controller.js new file mode 100644 index 0000000..9ef827f --- /dev/null +++ b/backend/src/modules/notifications/controller.js @@ -0,0 +1,20 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getNotifications = asyncHandler(async (req, res) => { + const [items, unread] = await Promise.all([ + service.listMine(req.currentUser.id), + service.countUnread(req.currentUser.id), + ]); + ok(res, { items, unread }); +}); + +export const patchNotificationRead = asyncHandler(async (req, res) => { + await service.markRead(Number(req.params.id), req.currentUser.id); + ok(res, { read: true }); +}); + +export const patchAllRead = asyncHandler(async (req, res) => { + await service.markAllRead(req.currentUser.id); + ok(res, { read: true }); +}); diff --git a/backend/src/modules/notifications/repository.js b/backend/src/modules/notifications/repository.js new file mode 100644 index 0000000..9850dcf --- /dev/null +++ b/backend/src/modules/notifications/repository.js @@ -0,0 +1,31 @@ +import { pool } from '../../config/db.js'; + +export async function findByUser(userId) { + const [rows] = await pool.query( + `SELECT id, type, message, entity_type, entity_id, is_read, created_at + FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`, + [userId] + ); + return rows; +} + +export async function countUnread(userId) { + const [rows] = await pool.query('SELECT COUNT(*) AS c FROM notifications WHERE user_id = ? AND is_read = 0', [userId]); + return rows[0].c; +} + +export async function create({ userId, type, message, entityType, entityId }) { + const [result] = await pool.query( + 'INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, ?, ?)', + [userId, type, message, entityType || null, entityId || null] + ); + return result.insertId; +} + +export async function markRead(id, userId) { + await pool.query('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?', [id, userId]); +} + +export async function markAllRead(userId) { + await pool.query('UPDATE notifications SET is_read = 1 WHERE user_id = ?', [userId]); +} diff --git a/backend/src/modules/notifications/routes.js b/backend/src/modules/notifications/routes.js new file mode 100644 index 0000000..2fb459d --- /dev/null +++ b/backend/src/modules/notifications/routes.js @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getNotifications, patchNotificationRead, patchAllRead } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getNotifications); +router.patch('/read-all', patchAllRead); +router.patch('/:id/read', patchNotificationRead); + +export default router; diff --git a/backend/src/modules/notifications/service.js b/backend/src/modules/notifications/service.js new file mode 100644 index 0000000..d8fc7d1 --- /dev/null +++ b/backend/src/modules/notifications/service.js @@ -0,0 +1,7 @@ +import * as repo from './repository.js'; + +export const listMine = (userId) => repo.findByUser(userId); +export const countUnread = (userId) => repo.countUnread(userId); +export const createNotification = (payload) => repo.create(payload); +export const markRead = (id, userId) => repo.markRead(id, userId); +export const markAllRead = (userId) => repo.markAllRead(userId); diff --git a/backend/src/modules/projects/controller.js b/backend/src/modules/projects/controller.js new file mode 100644 index 0000000..2a6f42e --- /dev/null +++ b/backend/src/modules/projects/controller.js @@ -0,0 +1,29 @@ +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 })); +}); + +export const getProject = asyncHandler(async (req, res) => { + ok(res, await service.getProject(Number(req.params.id))); +}); + +export const postProject = asyncHandler(async (req, res) => { + ok(res, await service.createProject(req.body, req.currentUser), 201); +}); + +export const putProject = asyncHandler(async (req, res) => { + ok(res, await service.updateProject(Number(req.params.id), req.body, req.currentUser)); +}); + +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); +}); + +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); +}); diff --git a/backend/src/modules/projects/repository.js b/backend/src/modules/projects/repository.js new file mode 100644 index 0000000..bd0e8aa --- /dev/null +++ b/backend/src/modules/projects/repository.js @@ -0,0 +1,119 @@ +import { pool } from '../../config/db.js'; + +const LIST_SELECT = ` + SELECT p.id, p.code, p.name, p.description, p.objective, p.project_type_id, pt.name AS project_type_name, + p.priority_id, pr.name AS priority_name, pr.color AS priority_color, + p.status_id, ps.name AS status_name, ps.color AS status_color, ps.is_closed AS status_is_closed, + p.owner_user_id, u.name AS owner_name, + 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 + FROM projects p + JOIN priorities pr ON pr.id = p.priority_id + JOIN project_statuses ps ON ps.id = p.status_id + JOIN users u ON u.id = p.owner_user_id + LEFT JOIN project_types pt ON pt.id = p.project_type_id + LEFT JOIN departments rd ON rd.id = p.requesting_department_id + LEFT JOIN departments sd ON sd.id = p.responsible_department_id +`; + +export async function findAll({ statusId, priorityId, ownerUserId } = {}) { + const clauses = []; + 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 [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]); + return rows[0] || null; +} + +export async function create(data) { + const [result] = await pool.query( + `INSERT INTO projects + (code, name, description, objective, project_type_id, priority_id, status_id, owner_user_id, + requesting_department_id, responsible_department_id, start_date, estimated_end_date, + risk_level, notes, created_by) + VALUES ('PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.name, data.description || null, data.objective || null, data.projectTypeId || null, + data.priorityId, data.statusId, data.ownerUserId, + data.requestingDepartmentId || null, data.responsibleDepartmentId || null, + data.startDate || null, data.estimatedEndDate || null, + data.riskLevel || 'low', data.notes || null, data.createdBy, + ] + ); + const id = result.insertId; + const code = `PRY-${String(id).padStart(4, '0')}`; + await pool.query('UPDATE projects SET code = ? WHERE id = ?', [code, id]); + // El creador queda como miembro por defecto. + await pool.query('INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)', [ + id, data.ownerUserId, 'owner', + ]); + return findById(id); +} + +export async function update(id, data) { + await pool.query( + `UPDATE projects SET + name = ?, description = ?, objective = ?, project_type_id = ?, priority_id = ?, status_id = ?, + owner_user_id = ?, requesting_department_id = ?, responsible_department_id = ?, + start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ? + WHERE id = ?`, + [ + data.name, data.description || null, data.objective || null, data.projectTypeId || null, + data.priorityId, data.statusId, data.ownerUserId, + data.requestingDepartmentId || null, data.responsibleDepartmentId || null, + data.startDate || null, data.estimatedEndDate || null, data.actualEndDate || null, + data.riskLevel || 'low', data.notes || null, id, + ] + ); + return findById(id); +} + +export async function updateProgress(id, progressPct) { + await pool.query('UPDATE projects SET progress_pct = ? WHERE id = ?', [progressPct, id]); +} + +export async function listMembers(projectId) { + const [rows] = await pool.query( + `SELECT pm.user_id, u.name, u.email, pm.role_in_project, pm.added_at + FROM project_members pm JOIN users u ON u.id = pm.user_id + WHERE pm.project_id = ? ORDER BY u.name`, + [projectId] + ); + return rows; +} + +export async function isMember(projectId, userId) { + const [rows] = await pool.query('SELECT 1 FROM project_members WHERE project_id = ? AND user_id = ?', [ + projectId, userId, + ]); + return rows.length > 0; +} + +export async function addMember(projectId, userId, roleInProject) { + await pool.query( + 'INSERT IGNORE INTO project_members (project_id, user_id, role_in_project) VALUES (?, ?, ?)', + [projectId, userId, roleInProject || null] + ); + 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); +} + +export async function averageActivityProgress(projectId) { + const [rows] = await pool.query('SELECT AVG(progress_pct) AS avg_pct FROM activities WHERE project_id = ?', [ + projectId, + ]); + return rows[0].avg_pct === null ? 0 : Number(rows[0].avg_pct); +} diff --git a/backend/src/modules/projects/routes.js b/backend/src/modules/projects/routes.js new file mode 100644 index 0000000..5ca0906 --- /dev/null +++ b/backend/src/modules/projects/routes.js @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getProjects, getProject, postProject, putProject, postMember, deleteMember } from './controller.js'; +import { getGantt } from '../dashboard/controller.js'; +import { getActivitiesByProject, postActivity } from '../activities/controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getProjects); +router.post('/', postProject); +router.get('/:id', getProject); +router.put('/:id', putProject); +router.get('/:id/gantt', getGantt); +router.post('/:id/members', postMember); +router.delete('/:id/members/:userId', deleteMember); +router.get('/:projectId/activities', getActivitiesByProject); +router.post('/:projectId/activities', postActivity); + +export default router; diff --git a/backend/src/modules/projects/service.js b/backend/src/modules/projects/service.js new file mode 100644 index 0000000..77c9478 --- /dev/null +++ b/backend/src/modules/projects/service.js @@ -0,0 +1,73 @@ +import * as repo from './repository.js'; +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. +export async function canWriteProject(user, project) { + if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) 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); +} + +export const listProjects = (filters) => repo.findAll(filters); + +export async function getProject(id) { + const project = await repo.findById(id); + if (!project) throw new HttpError(404, 'Proyecto no encontrado'); + const members = await repo.listMembers(id); + return { ...project, members }; +} + +export async function createProject(payload, currentUser) { + if (!payload.name || !payload.priorityId || !payload.statusId) { + throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); + } + if (![ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) { + throw new HttpError(403, 'No tienes permisos para crear proyectos'); + } + const project = await repo.create({ + ...payload, + ownerUserId: payload.ownerUserId || currentUser.id, + createdBy: currentUser.id, + }); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'project', entityId: project.id }); + return project; +} + +export async function updateProject(id, payload, 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'); + if (!payload.name || !payload.priorityId || !payload.statusId) { + throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); + } + const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id }); + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id }); + return updated; +} + +// Recalcula el avance del proyecto como promedio del avance de sus actividades. +export async function recalcProjectProgress(projectId) { + const avg = await repo.averageActivityProgress(projectId); + await repo.updateProgress(projectId, Math.round(avg * 100) / 100); +} + +export async function addMember(projectId, userId, roleInProject, 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.addMember(projectId, userId, roleInProject); + await logAudit({ userId: currentUser.id, action: 'add_member', 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'); + if (!(await canWriteProject(currentUser, project))) throw new HttpError(403, 'No tienes permisos sobre este proyecto'); + const members = await repo.removeMember(projectId, userId); + await logAudit({ userId: currentUser.id, action: 'remove_member', entityType: 'project', entityId: projectId, detail: { userId } }); + return members; +} diff --git a/backend/src/modules/subtasks/controller.js b/backend/src/modules/subtasks/controller.js new file mode 100644 index 0000000..185e986 --- /dev/null +++ b/backend/src/modules/subtasks/controller.js @@ -0,0 +1,23 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getSubtasks = asyncHandler(async (req, res) => { + ok(res, await service.listByActivity(Number(req.params.activityId))); +}); + +export const postSubtask = asyncHandler(async (req, res) => { + ok(res, await service.createSubtask(Number(req.params.activityId), req.body, req.currentUser), 201); +}); + +export const putSubtask = asyncHandler(async (req, res) => { + ok(res, await service.updateSubtask(Number(req.params.id), req.body, req.currentUser)); +}); + +export const patchSubtaskDone = asyncHandler(async (req, res) => { + ok(res, await service.setSubtaskDone(Number(req.params.id), !!req.body.isDone, req.currentUser)); +}); + +export const deleteSubtask = asyncHandler(async (req, res) => { + await service.removeSubtask(Number(req.params.id), req.currentUser); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/subtasks/repository.js b/backend/src/modules/subtasks/repository.js new file mode 100644 index 0000000..67e1337 --- /dev/null +++ b/backend/src/modules/subtasks/repository.js @@ -0,0 +1,49 @@ +import { pool } from '../../config/db.js'; + +export async function findByActivity(activityId) { + const [rows] = await pool.query( + `SELECT s.id, s.activity_id, s.name, s.responsible_user_id, u.name AS responsible_name, + s.is_done, s.due_date, s.created_at + FROM subtasks s LEFT JOIN users u ON u.id = s.responsible_user_id + WHERE s.activity_id = ? ORDER BY s.created_at`, + [activityId] + ); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query('SELECT * FROM subtasks WHERE id = ?', [id]); + return rows[0] || null; +} + +export async function create(activityId, { name, responsibleUserId, dueDate }) { + const [result] = await pool.query( + 'INSERT INTO subtasks (activity_id, name, responsible_user_id, due_date) VALUES (?, ?, ?, ?)', + [activityId, name, responsibleUserId || null, dueDate || null] + ); + return findById(result.insertId); +} + +export async function update(id, { name, responsibleUserId, dueDate }) { + await pool.query('UPDATE subtasks SET name = ?, responsible_user_id = ?, due_date = ? WHERE id = ?', [ + name, responsibleUserId || null, dueDate || null, id, + ]); + return findById(id); +} + +export async function setDone(id, isDone) { + await pool.query('UPDATE subtasks SET is_done = ? WHERE id = ?', [isDone ? 1 : 0, id]); + return findById(id); +} + +export async function remove(id) { + await pool.query('DELETE FROM subtasks WHERE id = ?', [id]); +} + +export async function countsByActivity(activityId) { + const [rows] = await pool.query( + 'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM subtasks WHERE activity_id = ?', + [activityId] + ); + return { total: rows[0].total, done: Number(rows[0].done) || 0 }; +} diff --git a/backend/src/modules/subtasks/routes.js b/backend/src/modules/subtasks/routes.js new file mode 100644 index 0000000..8820d26 --- /dev/null +++ b/backend/src/modules/subtasks/routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getSubtasks, postSubtask, putSubtask, patchSubtaskDone, deleteSubtask } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/activities/:activityId/subtasks', getSubtasks); +router.post('/activities/:activityId/subtasks', postSubtask); +router.put('/subtasks/:id', putSubtask); +router.patch('/subtasks/:id/done', patchSubtaskDone); +router.delete('/subtasks/:id', deleteSubtask); + +export default router; diff --git a/backend/src/modules/subtasks/service.js b/backend/src/modules/subtasks/service.js new file mode 100644 index 0000000..1b49898 --- /dev/null +++ b/backend/src/modules/subtasks/service.js @@ -0,0 +1,59 @@ +import * as repo from './repository.js'; +import * as activitiesRepo from '../activities/repository.js'; +import * as projectsRepo from '../projects/repository.js'; +import { canWriteActivity, recalcFromSubtasks } from '../activities/service.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; + +async function requireActivityAccess(activityId, currentUser) { + const activity = await activitiesRepo.findById(activityId); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const project = await projectsRepo.findById(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) { + throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + } + return activity; +} + +export const listByActivity = (activityId) => repo.findByActivity(activityId); + +export async function createSubtask(activityId, payload, currentUser) { + await requireActivityAccess(activityId, currentUser); + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + const subtask = await repo.create(activityId, payload); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'subtask', entityId: subtask.id }); + const counts = await repo.countsByActivity(activityId); + await recalcFromSubtasks(activityId, counts.done, counts.total); + return subtask; +} + +export async function updateSubtask(id, payload, currentUser) { + const subtask = await repo.findById(id); + if (!subtask) throw new HttpError(404, 'Subtarea no encontrada'); + await requireActivityAccess(subtask.activity_id, currentUser); + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + const updated = await repo.update(id, payload); + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'subtask', entityId: id }); + return updated; +} + +export async function setSubtaskDone(id, isDone, currentUser) { + const subtask = await repo.findById(id); + if (!subtask) throw new HttpError(404, 'Subtarea no encontrada'); + await requireActivityAccess(subtask.activity_id, currentUser); + const updated = await repo.setDone(id, isDone); + await logAudit({ userId: currentUser.id, action: isDone ? 'complete' : 'reopen', entityType: 'subtask', entityId: id }); + const counts = await repo.countsByActivity(subtask.activity_id); + await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total); + return updated; +} + +export async function removeSubtask(id, currentUser) { + const subtask = await repo.findById(id); + if (!subtask) throw new HttpError(404, 'Subtarea no encontrada'); + await requireActivityAccess(subtask.activity_id, currentUser); + await repo.remove(id); + await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'subtask', entityId: id }); + const counts = await repo.countsByActivity(subtask.activity_id); + await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total); +} diff --git a/backend/src/modules/users/controller.js b/backend/src/modules/users/controller.js new file mode 100644 index 0000000..50332b0 --- /dev/null +++ b/backend/src/modules/users/controller.js @@ -0,0 +1,20 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getUsers = asyncHandler(async (req, res) => ok(res, await service.listUsers())); +export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles())); + +export const postUser = asyncHandler(async (req, res) => { + const user = await service.createUser(req.body, req.currentUser.id); + ok(res, user, 201); +}); + +export const putUser = asyncHandler(async (req, res) => { + const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser.id); + ok(res, user); +}); + +export const patchUserActive = asyncHandler(async (req, res) => { + const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser.id); + ok(res, user); +}); diff --git a/backend/src/modules/users/repository.js b/backend/src/modules/users/repository.js new file mode 100644 index 0000000..29cdfbf --- /dev/null +++ b/backend/src/modules/users/repository.js @@ -0,0 +1,54 @@ +import { pool } from '../../config/db.js'; + +export async function findAll() { + const [rows] = await pool.query( + `SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, r.name AS role_name, + u.department_id, d.name AS department_name, u.position, u.is_active, u.created_at + FROM users u + JOIN roles r ON r.id = u.role_id + LEFT JOIN departments d ON d.id = u.department_id + ORDER BY u.name` + ); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query( + `SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active + FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`, + [id] + ); + return rows[0] || null; +} + +export async function findByEmail(email) { + const [rows] = await pool.query('SELECT id FROM users WHERE email = ?', [email]); + return rows[0] || null; +} + +export async function create({ name, email, passwordHash, roleId, departmentId, position }) { + const [result] = await pool.query( + `INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active) + VALUES (?, ?, ?, ?, ?, ?, 1)`, + [name, email, passwordHash, roleId, departmentId || null, position || null] + ); + return findById(result.insertId); +} + +export async function update(id, { name, roleId, departmentId, position }) { + await pool.query( + `UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ? WHERE id = ?`, + [name, roleId, departmentId || null, position || null, id] + ); + return findById(id); +} + +export async function setActive(id, isActive) { + await pool.query('UPDATE users SET is_active = ? WHERE id = ?', [isActive ? 1 : 0, id]); + return findById(id); +} + +export async function listRoles() { + const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id'); + return rows; +} diff --git a/backend/src/modules/users/routes.js b/backend/src/modules/users/routes.js new file mode 100644 index 0000000..300e3d1 --- /dev/null +++ b/backend/src/modules/users/routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js'; +import { getUsers, getRoles, postUser, putUser, patchUserActive } from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getUsers); +router.get('/roles', getRoles); +router.post('/', requireRole(...CAN_MANAGE_USERS), postUser); +router.put('/:id', requireRole(...CAN_MANAGE_USERS), putUser); +router.patch('/:id/active', requireRole(...CAN_MANAGE_USERS), patchUserActive); + +export default router; diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js new file mode 100644 index 0000000..2354130 --- /dev/null +++ b/backend/src/modules/users/service.js @@ -0,0 +1,41 @@ +import bcrypt from 'bcryptjs'; +import * as repo from './repository.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; + +export const listUsers = () => repo.findAll(); +export const listRoles = () => repo.listRoles(); + +export async function createUser(payload, actorId) { + if (!payload.name || !payload.email || !payload.password || !payload.roleId) { + throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios'); + } + const existing = await repo.findByEmail(payload.email); + if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email'); + + const passwordHash = await bcrypt.hash(payload.password, 12); + const user = await repo.create({ ...payload, passwordHash }); + await logAudit({ userId: actorId, action: 'create', entityType: 'user', entityId: user.id }); + return user; +} + +export async function updateUser(id, payload, actorId) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Usuario no encontrado'); + const user = await repo.update(id, payload); + await logAudit({ userId: actorId, action: 'update', entityType: 'user', entityId: id }); + return user; +} + +export async function setUserActive(id, isActive, actorId) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Usuario no encontrado'); + const user = await repo.setActive(id, isActive); + await logAudit({ + userId: actorId, + action: isActive ? 'activate' : 'deactivate', + entityType: 'user', + entityId: id, + }); + return user; +} diff --git a/backend/src/scripts/migrate-db.js b/backend/src/scripts/migrate-db.js new file mode 100644 index 0000000..98880a3 --- /dev/null +++ b/backend/src/scripts/migrate-db.js @@ -0,0 +1,34 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import mysql from 'mysql2/promise'; +import { env } from '../config/env.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const migrationsDir = path.resolve(__dirname, '../../database/migrations'); + +async function run() { + const connection = await mysql.createConnection({ + host: env.db.host, + port: env.db.port, + database: env.db.name, + user: env.db.user, + password: env.db.password, + multipleStatements: true, + }); + + const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort(); + for (const file of files) { + const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8'); + console.log(`[migrate] aplicando ${file}`); + await connection.query(sql); + } + + console.log(`[migrate] listo — ${files.length} archivo(s) aplicados`); + await connection.end(); +} + +run().catch((err) => { + console.error('[migrate] error:', err.message); + process.exit(1); +}); diff --git a/backend/src/scripts/seed-db.js b/backend/src/scripts/seed-db.js new file mode 100644 index 0000000..ca97f81 --- /dev/null +++ b/backend/src/scripts/seed-db.js @@ -0,0 +1,108 @@ +import bcrypt from 'bcryptjs'; +import mysql from 'mysql2/promise'; +import { env } from '../config/env.js'; + +async function run() { + const db = await mysql.createConnection({ + host: env.db.host, + port: env.db.port, + database: env.db.name, + user: env.db.user, + password: env.db.password, + }); + + // Roles fijos del MVP. + const roles = [ + ['admin', 'Administrador'], + ['it_manager', 'Jefe de TI'], + ['project_lead', 'Coordinador / Lider de proyecto'], + ['collaborator', 'Colaborador'], + ['viewer', 'Consultor (solo lectura)'], + ]; + for (const [code, name] of roles) { + await db.query('INSERT IGNORE INTO roles (code, name) VALUES (?, ?)', [code, name]); + } + + const priorities = [ + ['Baja', 1, 'muted'], + ['Media', 2, 'accent'], + ['Alta', 3, 'warning'], + ['Critica', 4, 'danger'], + ]; + for (const [name, level, color] of priorities) { + await db.query('INSERT IGNORE INTO priorities (name, level, color) VALUES (?, ?, ?)', [name, level, color]); + } + + const projectStatuses = [ + ['Planificado', 1, 'muted', 0], + ['En preparacion', 2, 'accent', 0], + ['En ejecucion', 3, 'accent', 0], + ['En pausa', 4, 'warning', 0], + ['Bloqueado', 5, 'danger', 0], + ['Completado', 6, 'success', 1], + ['Cancelado', 7, 'muted', 1], + ]; + for (const [name, sort, color, closed] of projectStatuses) { + await db.query( + 'INSERT IGNORE INTO project_statuses (name, sort_order, color, is_closed) VALUES (?, ?, ?, ?)', + [name, sort, color, closed] + ); + } + + const activityStatuses = [ + ['Pendiente', 1, 'muted', 0], + ['En progreso', 2, 'accent', 0], + ['En revision', 3, 'accent', 0], + ['Bloqueada', 4, 'danger', 0], + ['En espera', 5, 'warning', 0], + ['Completada', 6, 'success', 1], + ['Cancelada', 7, 'muted', 1], + ]; + for (const [name, sort, color, closed] of activityStatuses) { + await db.query( + 'INSERT IGNORE INTO activity_statuses (name, sort_order, color, is_closed) VALUES (?, ?, ?, ?)', + [name, sort, color, closed] + ); + } + + const projectTypes = ['Infraestructura', 'Desarrollo de software', 'Soporte', 'Seguridad', 'Mejora interna']; + for (const name of projectTypes) { + await db.query('INSERT IGNORE INTO project_types (name) VALUES (?)', [name]); + } + + const departments = [ + ['TI', null], + ['Finanzas', null], + ['RRHH', null], + ['Comercial', null], + ]; + const deptIds = {}; + for (const [name] of departments) { + await db.query('INSERT IGNORE INTO departments (name, parent_id) VALUES (?, NULL)', [name]); + const [rows] = await db.query('SELECT id FROM departments WHERE name = ? AND parent_id IS NULL', [name]); + deptIds[name] = rows[0].id; + } + const subDepartments = ['Desarrollo', 'Infraestructura', 'Soporte', 'Seguridad']; + for (const name of subDepartments) { + await db.query('INSERT IGNORE INTO departments (name, parent_id) VALUES (?, ?)', [name, deptIds['TI']]); + } + + // Usuario admin de dev — convencion del grupo: clave = usuario, SOLO en entornos no productivos. + const [adminRole] = await db.query("SELECT id FROM roles WHERE code = 'admin'"); + const [tiDept] = await db.query("SELECT id FROM departments WHERE name = 'TI' AND parent_id IS NULL"); + const passwordHash = await bcrypt.hash('mgonzalez', 12); + await db.query( + `INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active) + VALUES (?, ?, ?, ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE name = VALUES(name)`, + ['Milton Gonzalez', 'mgonzalez@linkedcorp.net', passwordHash, adminRole[0].id, tiDept[0].id, 'Jefe de TI'] + ); + + console.log('[seed] listo. Usuario dev: mgonzalez@linkedcorp.net / clave: mgonzalez'); + await db.end(); +} + +run().catch((err) => { + console.error('[seed] error:', err.message); + process.exit(1); +}); diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..c0c38a8 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,6 @@ +import { app } from './app.js'; +import { env } from './config/env.js'; + +app.listen(env.port, () => { + console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`); +}); diff --git a/backend/src/utils/audit.js b/backend/src/utils/audit.js new file mode 100644 index 0000000..0df1cee --- /dev/null +++ b/backend/src/utils/audit.js @@ -0,0 +1,8 @@ +import { pool } from '../config/db.js'; + +export async function logAudit({ userId, action, entityType, entityId, detail }) { + await pool.query( + `INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)`, + [userId, action, entityType, entityId ?? null, detail ? JSON.stringify(detail) : null] + ); +} diff --git a/backend/src/utils/code.js b/backend/src/utils/code.js new file mode 100644 index 0000000..a9b7fcd --- /dev/null +++ b/backend/src/utils/code.js @@ -0,0 +1,4 @@ +// Genera codigos legibles tipo PRY-0007 / ACT-0042 a partir del ultimo id insertado. +export function buildCode(prefix, id) { + return `${prefix}-${String(id).padStart(4, '0')}`; +} diff --git a/backend/src/utils/dates.js b/backend/src/utils/dates.js new file mode 100644 index 0000000..2238b7c --- /dev/null +++ b/backend/src/utils/dates.js @@ -0,0 +1,19 @@ +// Estado de vencimiento calculado al vuelo (no se guarda en BD). +// Reglas del SPEC: vence en >3 dias = normal, <=3 dias = advertencia, hoy = urgente, vencido = critico. +export function dueStatus(dueDate, isDone = false) { + if (!dueDate || isDone) return 'none'; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const due = new Date(dueDate); + due.setHours(0, 0, 0, 0); + const diffDays = Math.round((due - today) / 86400000); + + if (diffDays < 0) return 'overdue'; + if (diffDays === 0) return 'today'; + if (diffDays <= 3) return 'warning'; + return 'on_time'; +} + +export function todayMysql() { + return new Date().toISOString().slice(0, 10); +} diff --git a/backend/src/utils/http.js b/backend/src/utils/http.js new file mode 100644 index 0000000..a9dba10 --- /dev/null +++ b/backend/src/utils/http.js @@ -0,0 +1,18 @@ +export function ok(res, data = null, status = 200) { + return res.status(status).json({ success: true, data, error: null }); +} + +export function fail(res, status, message) { + return res.status(status).json({ success: false, data: null, error: message }); +} + +export class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +export function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} diff --git a/docs/SEGURIDAD-Y-CALIDAD.md b/docs/SEGURIDAD-Y-CALIDAD.md new file mode 100644 index 0000000..c0a5b2d --- /dev/null +++ b/docs/SEGURIDAD-Y-CALIDAD.md @@ -0,0 +1,109 @@ +# Seguridad y Calidad — project.linkedcorp.net + +> Copia ajustada de la plantilla del grupo (`project-md-templates/SEGURIDAD-Y-CALIDAD.md`). Léela +> antes de escribir código de una nueva funcionalidad y úsala como checklist antes de mergear. +> +> **Diferencia con la plantilla original**: la plantilla asume multi-empresa (`empresaId` por +> tenant). Este proyecto es de **una sola empresa** (single-company), así que el "aislamiento" que +> aplica aquí no es entre empresas sino entre **usuarios y su alcance por rol**: qué proyectos/ +> actividades puede ver o tocar cada rol (ver tabla de roles en `SPEC.md`). +> +> Regla de oro: **la seguridad primero; luego, cero duplicación; luego, facilidad de revisión y de +> extender.** + +--- + +## 1. Seguridad (NO negociable) + +### 1.1 Alcance por rol (equivalente al aislamiento multi-tenant) +- **NUNCA** confíes en un `userId`/`role` que venga en el **body/query**. El usuario y su rol salen + SIEMPRE del token (`req.currentUser`), nunca del body. +- Reglas de alcance por rol (ver `SPEC.md`): + - **Colaborador**: solo puede ver/actualizar actividades y subtareas donde es `responsible_user_id`, + y comentar en los proyectos donde es miembro. + - **Coordinador/Líder**: solo puede crear/editar proyectos donde es `owner_user_id` o miembro + (`project_members`), y las actividades de esos proyectos. + - **Jefe de TI** y **Administrador**: sin restricción de alcance. + - **Consultor**: solo lectura en todo, ningún endpoint de escritura le responde 2xx. +- Antes de `update`/`delete`/`get by id` de un recurso, verificar que el usuario tiene alcance sobre + ese recurso (por rol + pertenencia al proyecto) y responder **404** si no corresponde (no 403, + para no revelar existencia a quien no debería ni saber que existe). +- Los IDs relacionados que llegan en el body (`projectId`, `activityId`, `dependsOnActivityId`, etc.) + deben validarse: que existan y que el usuario tenga alcance sobre ellos antes de usarlos. + +### 1.2 Autenticación y autorización +- Toda ruta no pública pasa por middleware de auth (`backend/src/middleware/auth.js`). +- **Revalida en CADA request** que la cuenta siga activa (`users.is_active`). Un JWT válido por firma + NO basta: desactivar un usuario debe cortarle el acceso al instante, no cuando expire el token. +- La autorización del **frontend es solo UX** (ocultar botones/menús); el backend re-verifica rol y + alcance en cada endpoint. El usuario en `localStorage` es manipulable. + +### 1.3 Secretos +- Contraseñas: `bcrypt` (cost ≥ 12). Nunca en logs ni respuestas. +- `.env` fuera de git. El arranque del backend debe **abortar en producción** si `JWT_SECRET` o las + credenciales de BD son valores por defecto/vacíos. +- No exponer rutas absolutas del filesystem del servidor en respuestas de API. + +### 1.4 Entrada y datos +- **SQL siempre parametrizado** (placeholders `?`). Nunca concatenar entrada del usuario. Las + cláusulas `IN (...)` dinámicas se arman con placeholders, no con el valor. +- Nombres de columna/orden dinámicos SOLO desde una **whitelist** del servidor, jamás desde el query + string tal cual. +- Uploads (`attachments`): validar tamaño y **MIME por whitelist**. El nombre/ruta del archivo lo + genera el servidor (evita path traversal); nunca usar el nombre que manda el cliente como ruta. +- Fechas: validar coherencia (`due_date >= start_date`, etc.) en el servicio, no solo en el frontend. + +### 1.5 Errores, logs y límites +- Los **5xx** ocultan el detalle al cliente en producción. +- **Rate limiting** en `POST /api/auth/login`. +- Sirviendo la SPA: CSP (helmet) — `script-src 'self'` (sin inline), `style-src 'self' 'unsafe-inline'`, + `img-src 'self' data: blob:`, `object-src 'none'`. + +### 1.6 Frontend / caché +- El JWT en `localStorage` es un trade-off aceptado; mantener CSP y sanitización. +- Si en el futuro se agrega caché offline, purgarla en logout y en `401`. + +--- + +## 2. Cero duplicación (procesos, flujos y funciones) + +- Un helper vive en **un solo lugar**. Antes de escribir una función, buscar si ya existe. +- Helpers compartidos en `backend/src/utils/` (fechas, cálculo de `progress_pct`, cálculo de estado + de vencimiento 🟢/🟡/🔴) y `frontend/src/services`/`lib` (formateadores, `fmtDate`, `roleLabel`). +- El patrón CRUD repetido (listar/buscar/crear/editar/borrar) se abstrae en un hook reutilizable en + el frontend (`useAsync`/`useCrud`); loading/try-catch/ContentDialog de error, en un solo lugar. +- Una sola capa de API en el frontend (`src/services/api.js` con `api.get/post/put/delete`). + +--- + +## 3. Orden, revisión y facilidad de extender + +- Arquitectura backend por módulo: **routes → controller → service → repository → db**. Un módulo + nuevo replica esa forma (ver `projects`/`activities` como plantilla). +- **Un archivo, una responsabilidad.** Si un `service` crece mucho o mezcla temas, partirlo. +- Sin código muerto. +- Convención única de respuestas: `{ success, data, error }` en todos los endpoints; listados con + `{ items, total }` dentro de `data`. +- El cálculo de `progress_pct` (actividad y proyecto) vive en **un solo lugar** (`activities/service.js` + y `projects/service.js`), nunca duplicado en el frontend como fuente de verdad — el frontend solo + muestra el valor que devuelve la API. + +--- + +## 4. Checklist antes de mergear / desplegar + +Seguridad +- [ ] ¿El usuario/rol sale del token, no del body? +- [ ] ¿`update/delete/get-by-id` verifican alcance por rol (404 si no corresponde)? +- [ ] ¿La ruta tiene el middleware de auth/rol correcto? ¿Se revalida `is_active`? +- [ ] ¿SQL parametrizado (incl. `IN`)? ¿MIME/tamaño validados en adjuntos? +- [ ] ¿Secretos fuera de respuestas/logs? ¿5xx sin datos internos? + +Calidad +- [ ] ¿Reutilicé helpers existentes en vez de duplicar? +- [ ] ¿Borré el código muerto que este cambio deja atrás? +- [ ] ¿El archivo/módulo sigue el patrón y no se volvió inmanejable? + +Verificación +- [ ] Build/backend arrancan sin errores; smoke test del flujo tocado en dev. +- [ ] Migraciones idempotentes; respaldo antes de aplicarlas en producción (cuando exista deploy). diff --git a/docs/UI-UX-WINDOWS.html b/docs/UI-UX-WINDOWS.html new file mode 100644 index 0000000..e9f793a --- /dev/null +++ b/docs/UI-UX-WINDOWS.html @@ -0,0 +1,1265 @@ + + + + + +Identidad UI/UX — Windows (shell nombrado, muestra viva) + + + + +
+
+ + +
+ barra-titulo +
LinkedCorp Desktop
+
+ + + +
+
+ +
+ + + + +
+ col-derecha + + +
+ barra-comandos + Resumen +
+ + + +
+ +
+
+ + +
+ migas-pan + + ›Resumen +
+ + +
+ area-contenido + + +
+
+

Guía viva — identidad Windows

+

Esta ventana simula el shell de una app de escritorio moderna (estilo Fluent / Windows 11), pensada para mouse: hover en cada control, clic derecho en las filas del listado, y arrastra el divisor en “Maestro-detalle”. Activa “Nombres” (barra de comandos › ⋯) para ver el nombre de cada región/componente y pedir cambios por su nombre exacto.

+
+ +
+
+ +
+

Botones (Accent / Standard / Subtle / Danger / Hyperlink)

+
+ + + + + + + +
+
+ +
+
+

Colores (tokens)

+
+ Info + Éxito + Atención + Error +
+

Acento del sistema (Windows deja elegir uno):

+
+
+
+

Campos

+
+
+ Este campo es obligatorio.
+
+
+ +
+

Estados de carga

+
+
+
+
+
+
+
+ + +
+
+

Indicadores

+
+
Ventas del mes
$48,210
▲ 12.4%
+
Clientes activos
1,284
▲ 3.1%
+
Tickets abiertos
37
▼ 5.0%
+
+
+
+

Ventas (línea)

+

Distribución (dona)

+
+
+ + +
+
+

ListView — selección, orden, clic derecho

+

Clic en una fila selecciona; clic derecho abre el menú contextual; clic en el encabezado ordena la columna.

+
+
buscador
+
+
Todos
+
+
Todos
+
Activo
+
Pendiente
+
Inactivo
+
+
+ +
+
listview + + + + + + + + + +
CódigoClienteMontoEstado
+
+
+ Líneas por página: + + + +
+
+
+
+ + +
+
+

Maestro-detalle (Split View)

+

Patrón nativo de Windows (Correo, Configuración, Explorador): al seleccionar un ítem de la lista, el panel derecho se actualiza en el sitio, sin modal. Arrastra el divisor central para redimensionar.

+
panel-detalle +
+
+
+
+
+
+ + +
+
+

Controles de formulario

+
+
+
+
+
+
+
+
Selecciona…
+
+
+
+
+
+
+
+
Elegir fecha…
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+ + +
+
+

ContentDialog — REGLA DE ORO para decisiones

+

Nunca alert()/confirm()/MessageBox nativo. Toda decisión pasa por un ContentDialog propio, centrado, con acción primaria a la derecha.

+
+ + + + + + +
+
+
+

InfoBar — aviso no bloqueante embebido en el contenido

+
+
Sincronización activaLos cambios se guardan automáticamente cada 30s.
+
Licencia por vencerQuedan 5 días de tu plan actual.
+
+ +
+
+
+

Toast — solo eventos de fondo

+

Para procesos sin interacción (ej. "Copia de seguridad completada"). No sirve para pedir una decisión.

+ +
+
+

Teaching Tip — guía contextual

+

Coach mark anclado a un control, para onboarding de una función nueva.

+ +
+
+
+ + +
+
+

Barra de menú clásica (opcional, apps de productividad)

+ +

Se abre con clic; también responde a clic derecho en cualquier punto de esta tarjeta.

+
+ +
+
+

Menú contextual (clic derecho)

+
menucontextualClic derecho (o clic) aquí para ver el menú
+
+
+

Flyout de usuario

+

Anclado al avatar del panel de navegación (ábrelo desde la esquina inferior izquierda).

+ +
+
+
+ + +
+
+

Árbol jerárquico (estilo Explorador)

+
    +
    +
    + + +
    +
    +

    Configuración (patrón Expander de Windows 11)

    +
    +
    + +
    PersonalizaciónTema, acento e idioma
    + +
    +
    +
    + Tema + + +
    +
    + Idioma + + +
    +
    Acento del sistema
    +
    +
    +
    +
    + +
    NotificacionesControla los avisos de la aplicación
    + +
    +
    +
    + +
    +
    +
    +
    + +
    Cuenta y seguridadSesión, cierre y datos
    + +
    +
    +
    +
    +
    + + +
    +
    +
    +

    Ventana de inicio de sesión

    +
    +
    +
    Iniciar sesión
    +
    +
    MG
    +
    +
    + + +
    +
    +
    +
    +
    +

    Ventana “Acerca de”

    +
    +
    +
    Acerca de
    +
    + +

    LinkedCorp Desktop

    +

    Versión 1.4.0 — © 2026 LinkedCorp

    + +
    +
    +
    +
    +
    +
    +

    Reporte / vista previa de impresión

    + +
    +
    +
    + + +
    +
    +

    Galería de íconos (Lucide, local)

    +

    Un solo set en toda la app; los íconos heredan el color del contexto (activo = acento). Alternativa 100% nativa: Segoe Fluent Icons (fuente del sistema Windows) manteniendo la misma nomenclatura de claves.

    +
    + +
    +
    +
    + +
    + + +
    + barra-estado + Listo +
    + + + v1.4.0 +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    +

    Milton González

    +

    miltongonzalez74@gmail.com

    +
    +
    + +
    +

    Nuevo: clic derecho

    +

    Ahora puedes hacer clic derecho sobre cualquier fila para ver acciones rápidas.

    +
    +
    + +
    + + + + + + diff --git a/docs/UI-UX-WINDOWS.md b/docs/UI-UX-WINDOWS.md new file mode 100644 index 0000000..dde6d0b --- /dev/null +++ b/docs/UI-UX-WINDOWS.md @@ -0,0 +1,273 @@ +# UI/UX — Identidad para aplicaciones de escritorio Windows (cómo construir pantallas) + +> 🖥️ **Muestra viva:** `UI-UX-WINDOWS.html` (en esta misma carpeta) — ábrela en el navegador para +> VER todos los elementos (ventana con barra de título propia, panel de navegación, comandos, +> listado con clic derecho, maestro-detalle, diálogos, flyouts, configuración). **Es lo primero que +> se le muestra al usuario** al arrancar un proyecto de escritorio Windows para que apruebe o pida +> ajustes de la identidad antes de construir. Usa dos JS **locales** junto al HTML (sin CDN, offline): +> `lucide.min.js` (íconos) y `chart.umd.js` (gráficas Chart.js) — cópialos junto al HTML si se mueve. + +> ⚠️ **Este documento es la referencia visual y de interacción** (look & feel, tokens, componentes, +> nomenclatura de regiones). **No asume una tecnología de implementación concreta**: sirve tanto para +> una app WinUI 3/.NET nativa como para una app Electron/Tauri, o cualquier otro stack de escritorio +> Windows. Cada proyecto decide su stack al copiar este documento; lo que **no cambia** es el +> comportamiento y la identidad descritos aquí — son los que hacen que la app "se sienta Windows". + +Estilo de referencia: **Fluent Design / Windows 11** (Mica, esquinas redondeadas, tipografía Segoe UI +Variable, paleta de acento del sistema). Pensado **primero para mouse** (hover, clic derecho, arrastre), +sin dejar de ser accesible por teclado. + +--- + +## 1. Identidad visual (tokens y tema) + +Colores como **variables CSS RGB** (o el equivalente del framework elegido: `DynamicResource` en +WPF/WinUI, CSS vars en Electron/Tauri), para poder escribir `rgb(var(--accent)/.2)` y variantes con +transparencia. Tema **oscuro por defecto** + claro vía `data-theme="light"`. + +| Token | Uso | Oscuro (ref.) | Claro (ref.) | +|---|---|---|---| +| `canvas` | fondo de ventana (equivalente a Mica) | `32 32 32` | `243 243 243` | +| `surface` | tarjetas, diálogos, flyouts | `43 43 43` | `255 255 255` | +| `panel` | panel de navegación, hover de filas | `51 51 51` | `249 249 249` | +| `field` | fondo de campos de entrada | `43 43 43` | `255 255 255` | +| `body` | texto principal | `255 255 255` | `32 32 32` | +| `muted` | texto secundario, labels, hints | `168 168 168` | `96 96 96` | +| `line` | bordes y divisores | `70 70 70` | `225 225 225` | +| `accent` | acción primaria — **el acento del sistema** | `0 120 212` | `0 103 192` | +| `success` / `warning` / `danger` | estados | `108 203 95` / `255 185 0` / `255 105 97` | `16 124 16` / `157 93 0` / `196 43 28` | + +**Acento del sistema (do & don't):** Windows deja al usuario elegir un color de acento en la +configuración del sistema; la app debe **heredarlo** cuando el framework lo expone (WinUI 3 lo hace +automático vía `AccentColor`), o dar un selector propio con la paleta estándar si no hay acceso al +acento del SO. Paleta de referencia (la que usa la muestra viva): `blue #0078D4` (por defecto), +`purple #8764B8`, `green #10893E`, `red #C42B1C`, `orange #CA5010`, `teal #009984`. + +**Tipografía:** **Segoe UI Variable** (Windows 11) como fuente del sistema — no requiere carga de +fuente web, ya está instalada en Windows: `font-family:'Segoe UI Variable Text','Segoe UI',system-ui,sans-serif`. +Los títulos grandes usan el corte *Display* de la misma familia (`Segoe UI Variable Display`). +Escala: `.72rem` metadatos/labels · `.8–.82rem` cuerpo/controles · `1.05–1.25rem` títulos de sección · +`1.6rem+` valores KPI/Display. Pesos: 600–700 títulos y labels, 400 párrafos. + +**Forma (radios de Fluent 2):** `--r-sm: 4px` en controles (botones, campos, checkboxes) y +`--r-lg: 8px` en superficies elevadas (tarjetas, diálogos, flyouts, menús, y la propia ventana). +No usar esquinas totalmente cuadradas ni "pill" (`999px`) salvo en `ToggleSwitch`, `badge` y `avatar`. + +**Iconografía:** un solo set en toda la app. Dos opciones válidas: +- **Lucide** (la que usa la muestra viva, `lucide.min.js` local) — mismo estándar que ya usa el grupo + en sus apps web (`UI-UX-LINKEDCORP.md`), da continuidad visual entre plataformas y permite compartir + nombres de ícono entre el proyecto web y el de escritorio de un mismo cliente. +- **Segoe Fluent Icons** (fuente de glifos del propio Windows) — 100% nativo, cero peso adicional, pero + solo tiene sentido en una implementación WinUI/WPF nativa (no está disponible como webfont fuera de Windows). +- Los íconos **heredan el color del contexto** (activo = `accent`, texto = `body`/`muted`). No mezclar + ambos sets en la misma app. + +--- + +## 2. Mensajes del sistema — jerarquía de avisos (REGLA DE ORO) + +A diferencia de la web (donde todo pasa por un único `AppDialog`), una app de escritorio Windows tiene +**cuatro** mecanismos de aviso, cada uno con un propósito distinto. Elegir el correcto es parte de la +identidad: + +| Mecanismo | Para qué | Bloquea | Ejemplo | +|---|---|---|---| +| **ContentDialog** (modal) | **toda decisión** del usuario (confirmar, elegir, error crítico) | Sí | "¿Eliminar el cliente CLI-002?" | +| **InfoBar** | estado o aviso **no bloqueante**, embebido en el contenido de la pantalla | No | "Sincronización activa", "Licencia por vencer" | +| **Toast/notificación** | evento de **fondo**, sin interacción esperada, autodesaparece | No | "Copia de seguridad completada" | +| **Flyout** | acción o info **contextual**, anclada a un control, se cierra al hacer clic fuera | No (light-dismiss) | menú de usuario, "¿seguro?" ligado a un botón puntual | + +**Nunca** `MessageBox`/`alert`/`confirm` del sistema operativo ni del navegador si el shell es +web-based: siempre el `ContentDialog` propio, para mantener consistencia visual entre Windows y el resto +de plataformas del grupo. + +Contrato del `ContentDialog` (equivalente al `openDialog` del template web): +```js +openDialog({ + tone: 'info' | 'success' | 'warning' | 'error', // franja superior + color del ícono/título + title: 'Título claro', + message: 'Descripción en lenguaje del usuario', + actions: [ // opcional; sin actions -> botón "Cerrar" + { label: 'Cancelar', variant: 'standard' }, + { label: 'Eliminar', variant: 'danger', onClick: async () => { ... }, keepOpen: false }, + ], +}); +``` +- Sin `actions` ⇒ botón "Cerrar" (`accent`) automático. `keepOpen: true` mantiene el diálogo abierto + tras el `onClick` (procesos largos). +- **Sets de botones estándar** (misma convención que la identidad web): Aceptar/Cancelar, + Sí/No, Eliminar(danger)/Cancelar, Guardar/Descartar. **Acción primaria a la derecha**, Cancelar/No a + la izquierda. Los botones de decisión **siempre muestran texto** (nunca solo ícono). +- El `ContentDialog` se centra sobre la **ventana de la app**, no sobre toda la pantalla del monitor. + +--- + +## 3. Kit de componentes (resumen; ver el markup/CSS de referencia en `UI-UX-WINDOWS.html`) + +- **Botones** — `accent` (relleno de color, acción primaria), `standard` (borde + fondo neutro), + `subtle` (transparente, solo hover), `danger` (borde/texto rojo). Alto `32px` (`sm` `26px`). Todo + botón compone **ícono (izq.) + texto**; en toolbars muy densas puede ir solo ícono con `tooltip` + obligatorio indicando la acción. +- **Campos** — `TextBox`, `NumberBox` (con spinner +/-), `ComboBox` propio (desplegable redondeado, + con buscador si la lista tiene **≥10 ítems**; nunca el ` + + + {files.length === 0 &&
    {t('common_empty')}
    } + {files.map((f) => ( +
    + +
    +
    {f.original_name}
    +
    {f.uploaded_by_name} · {(f.size / 1024).toFixed(0)} KB
    +
    + + +
    + ))} + + ); +} diff --git a/frontend/src/components/Badge.jsx b/frontend/src/components/Badge.jsx new file mode 100644 index 0000000..0ea0f44 --- /dev/null +++ b/frontend/src/components/Badge.jsx @@ -0,0 +1,9 @@ +const COLOR_MAP = { + accent: 'badge-accent', success: 'badge-success', warning: 'badge-warning', + danger: 'badge-danger', muted: 'badge-muted', +}; + +export function Badge({ color = 'accent', children }) { + const cls = COLOR_MAP[color] || 'badge-accent'; + return {children}; +} diff --git a/frontend/src/components/Button.jsx b/frontend/src/components/Button.jsx new file mode 100644 index 0000000..b699df6 --- /dev/null +++ b/frontend/src/components/Button.jsx @@ -0,0 +1,9 @@ +export function Button({ variant = 'standard', size, icon: Icon, children, className = '', ...rest }) { + const sizeClass = size === 'sm' ? 'btn-sm' : ''; + return ( + + ); +} diff --git a/frontend/src/components/ComboBox.jsx b/frontend/src/components/ComboBox.jsx new file mode 100644 index 0000000..54b07ba --- /dev/null +++ b/frontend/src/components/ComboBox.jsx @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronDown, Search } from 'lucide-react'; + +// Combo con buscador (regla del grupo: nunca el setQuery(e.target.value)} + placeholder="Buscar..." + /> + + )} + {filtered.length === 0 &&
    Sin resultados
    } + {filtered.map((o) => ( +
    { onChange(o.value); setOpen(false); setQuery(''); }} + > + {o.label} +
    + ))} + + )} + + ); +} diff --git a/frontend/src/components/CommentsPanel.jsx b/frontend/src/components/CommentsPanel.jsx new file mode 100644 index 0000000..1256913 --- /dev/null +++ b/frontend/src/components/CommentsPanel.jsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import { Send } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; +import { useAsync, useAction } from '../hooks/useAsync.js'; +import { listComments, createComment } from '../services/comments.js'; +import { Button } from './Button.jsx'; + +export function CommentsPanel({ entityType, entityId }) { + const { t } = useUi(); + const runAction = useAction(); + const { data: comments, loading, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]); + const [message, setMessage] = useState(''); + + async function send() { + if (!message.trim()) return; + await runAction(async () => { + await createComment(entityType, entityId, message.trim()); + setMessage(''); + await reload(); + }); + } + + if (loading || !comments) return
    {t('common_loading')}
    ; + + return ( +
    + {comments.length === 0 &&
    {t('common_empty')}
    } + {comments.map((c) => ( +
    +
    {c.user_name} · {c.created_at}
    +
    {c.message}
    +
    + ))} +
    + setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} /> + +
    +
    + ); +} diff --git a/frontend/src/components/ContentDialog.jsx b/frontend/src/components/ContentDialog.jsx new file mode 100644 index 0000000..4795452 --- /dev/null +++ b/frontend/src/components/ContentDialog.jsx @@ -0,0 +1,38 @@ +import { useUi } from '../context/ui-context.jsx'; +import { Button } from './Button.jsx'; + +const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', error: 'danger' }; + +// ContentDialog global — equivalente al AppDialog de la identidad web. Contrato: +// openDialog({ tone, title, message, actions: [{ label, variant, keepOpen, onClick }] }) +export function ContentDialogHost() { + const { dialog, closeDialog, t } = useUi(); + if (!dialog) return null; + + const tone = dialog.tone || 'info'; + const actions = dialog.actions?.length ? dialog.actions : [{ label: t('common_close'), variant: 'accent' }]; + + async function handleClick(action) { + if (action.onClick) await action.onClick(); + if (!action.keepOpen) closeDialog(); + } + + return ( +
    { if (e.target === e.currentTarget) closeDialog(); }}> +
    +
    +
    +
    {dialog.title}
    +
    {dialog.message}
    +
    +
    + {actions.map((action, i) => ( + + ))} +
    +
    +
    + ); +} diff --git a/frontend/src/components/Expander.jsx b/frontend/src/components/Expander.jsx new file mode 100644 index 0000000..300aa92 --- /dev/null +++ b/frontend/src/components/Expander.jsx @@ -0,0 +1,19 @@ +import { useState } from 'react'; +import { ChevronRight } from 'lucide-react'; + +export function Expander({ icon: Icon, title, description, defaultOpen = false, children }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
    +
    setOpen((v) => !v)}> + {Icon && } +
    +
    {title}
    + {description &&
    {description}
    } +
    + +
    + {open &&
    {children}
    } +
    + ); +} diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx new file mode 100644 index 0000000..656506e --- /dev/null +++ b/frontend/src/components/Field.jsx @@ -0,0 +1,13 @@ +export function Field({ label, children, hint }) { + return ( +
    + {label && } + {children} + {hint && {hint}} +
    + ); +} + +export function FieldRow({ children }) { + return
    {children}
    ; +} diff --git a/frontend/src/components/Flyout.jsx b/frontend/src/components/Flyout.jsx new file mode 100644 index 0000000..a509e0e --- /dev/null +++ b/frontend/src/components/Flyout.jsx @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +// Flyout generico con light-dismiss: click fuera o Esc cierra. trigger recibe { onClick, ref }. +export function Flyout({ trigger, children, align = 'end' }) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState({ top: 0, left: 0 }); + const anchorRef = useRef(null); + + function openFlyout() { + const rect = anchorRef.current.getBoundingClientRect(); + setPos({ + top: rect.bottom + 6, + left: align === 'end' ? rect.right : rect.left, + }); + setOpen(true); + } + + useEffect(() => { + if (!open) return; + function onKey(e) { if (e.key === 'Escape') setOpen(false); } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open]); + + return ( + <> + {trigger({ onClick: () => (open ? setOpen(false) : openFlyout()) })} + {open && createPortal( + <> +
    setOpen(false)} /> +
    setOpen(false)} + > + {children} +
    + , + document.body + )} + + ); +} + +export function FlyoutItem({ icon: Icon, danger, children, ...rest }) { + return ( + + ); +} + +export function FlyoutSeparator() { + return
    ; +} diff --git a/frontend/src/components/InfoBar.jsx b/frontend/src/components/InfoBar.jsx new file mode 100644 index 0000000..586741c --- /dev/null +++ b/frontend/src/components/InfoBar.jsx @@ -0,0 +1,21 @@ +import { useState } from 'react'; +import { Info, CheckCircle2, AlertTriangle, XCircle, X } from 'lucide-react'; + +const ICONS = { info: Info, success: CheckCircle2, warning: AlertTriangle, error: XCircle }; + +export function InfoBar({ tone = 'info', children, dismissible = true }) { + const [visible, setVisible] = useState(true); + if (!visible) return null; + const Icon = ICONS[tone] || Info; + return ( +
    + +
    {children}
    + {dismissible && ( + + )} +
    + ); +} diff --git a/frontend/src/components/KpiCard.jsx b/frontend/src/components/KpiCard.jsx new file mode 100644 index 0000000..93619c9 --- /dev/null +++ b/frontend/src/components/KpiCard.jsx @@ -0,0 +1,8 @@ +export function KpiCard({ label, value }) { + return ( +
    +
    {value}
    +
    {label}
    +
    + ); +} diff --git a/frontend/src/components/ListView.jsx b/frontend/src/components/ListView.jsx new file mode 100644 index 0000000..e0d40ea --- /dev/null +++ b/frontend/src/components/ListView.jsx @@ -0,0 +1,72 @@ +import { useMemo, useState } from 'react'; +import { MoreVertical } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; +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 }) { + const { t } = useUi(); + const [sort, setSort] = useState({ key: null, dir: 1 }); + + const filtered = useMemo(() => { + if (!searchValue) return rows; + const q = searchValue.toLowerCase(); + return rows.filter((r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase().includes(q)); + }, [rows, searchValue, getSearchHaystack]); + + const sorted = useMemo(() => { + if (!sort.key) return filtered; + const col = columns.find((c) => c.key === sort.key); + return [...filtered].sort((a, b) => { + const va = col.sortValue ? col.sortValue(a) : a[sort.key]; + const vb = col.sortValue ? col.sortValue(b) : b[sort.key]; + if (va === vb) return 0; + return (va > vb ? 1 : -1) * sort.dir; + }); + }, [filtered, sort, columns]); + + function toggleSort(key) { + setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 })); + } + + return ( +
    + + + + {columns.map((col) => ( + + ))} + {rowActions && + + + {sorted.map((row) => ( + onRowClick?.(row)}> + {columns.map((col) => ( + + ))} + {rowActions && ( + + )} + + ))} + +
    col.sortable !== false && toggleSort(col.key)}> + {col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''} + } +
    {col.render ? col.render(row) : row[col.key]} e.stopPropagation()}> + ( + + )}> + {rowActions(row).map((action, i) => ( + + {action.label} + + ))} + +
    + {sorted.length === 0 &&
    {t('common_empty')}
    } +
    + ); +} diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx new file mode 100644 index 0000000..6c8b058 --- /dev/null +++ b/frontend/src/components/NavigationView.jsx @@ -0,0 +1,31 @@ +import { NavLink } from 'react-router-dom'; +import { LayoutDashboard, FolderKanban, Building2, Settings } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; + +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' }, +]; + +export function NavigationView({ mode }) { + const { t } = useUi(); + return ( + + ); +} diff --git a/frontend/src/components/SearchInput.jsx b/frontend/src/components/SearchInput.jsx new file mode 100644 index 0000000..9381252 --- /dev/null +++ b/frontend/src/components/SearchInput.jsx @@ -0,0 +1,12 @@ +import { Search } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; + +export function SearchInput({ value, onChange, placeholder }) { + const { t } = useUi(); + return ( +
    + + onChange(e.target.value)} placeholder={placeholder || t('common_search')} /> +
    + ); +} diff --git a/frontend/src/components/StatusBadge.jsx b/frontend/src/components/StatusBadge.jsx new file mode 100644 index 0000000..4fe126f --- /dev/null +++ b/frontend/src/components/StatusBadge.jsx @@ -0,0 +1,26 @@ +import { Badge } from './Badge.jsx'; +import { useUi } from '../context/ui-context.jsx'; + +export function StatusBadge({ name, color }) { + return {name}; +} + +const DUE_COLOR = { on_time: 'success', warning: 'warning', today: 'warning', overdue: 'danger', none: 'muted' }; +const DUE_KEY = { on_time: 'due_on_time', warning: 'due_warning', today: 'due_today', overdue: 'due_overdue' }; + +export function DueBadge({ status }) { + const { t } = useUi(); + if (!status || status === 'none') return null; + return {t(DUE_KEY[status])}; +} + +export function ProgressBar({ value }) { + return ( +
    +
    +
    +
    + {Math.round(value)}% +
    + ); +} diff --git a/frontend/src/components/Tabs.jsx b/frontend/src/components/Tabs.jsx new file mode 100644 index 0000000..1cbb849 --- /dev/null +++ b/frontend/src/components/Tabs.jsx @@ -0,0 +1,11 @@ +export function Tabs({ tabs, active, onChange }) { + return ( +
    + {tabs.map((tab) => ( + + ))} +
    + ); +} diff --git a/frontend/src/components/ToastHost.jsx b/frontend/src/components/ToastHost.jsx new file mode 100644 index 0000000..dc87014 --- /dev/null +++ b/frontend/src/components/ToastHost.jsx @@ -0,0 +1,13 @@ +import { useUi } from '../context/ui-context.jsx'; + +export function ToastHost() { + const { toasts } = useUi(); + if (!toasts.length) return null; + return ( +
    + {toasts.map((toast) => ( +
    {toast.message}
    + ))} +
    + ); +} diff --git a/frontend/src/components/Treeview.jsx b/frontend/src/components/Treeview.jsx new file mode 100644 index 0000000..bf7f7df --- /dev/null +++ b/frontend/src/components/Treeview.jsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { ChevronRight, ChevronDown, Folder } from 'lucide-react'; + +function TreeNode({ node, level, onSelect, selectedId }) { + const [open, setOpen] = useState(true); + const hasChildren = node.children && node.children.length > 0; + return ( +
    +
    onSelect?.(node)} + > + {hasChildren ? ( + + ) : ( + + )} + + {node.name} +
    + {hasChildren && open && node.children.map((child) => ( + + ))} +
    + ); +} + +export function Treeview({ nodes, onSelect, selectedId }) { + return ( +
    + {nodes.map((node) => ( + + ))} +
    + ); +} diff --git a/frontend/src/context/auth-context.jsx b/frontend/src/context/auth-context.jsx new file mode 100644 index 0000000..8007ca8 --- /dev/null +++ b/frontend/src/context/auth-context.jsx @@ -0,0 +1,44 @@ +import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react'; +import { api, setUnauthorizedHandler } from '../services/api.js'; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [ready, setReady] = useState(false); + + const logout = useCallback(() => { + api.setToken(null); + setUser(null); + }, []); + + useEffect(() => { + setUnauthorizedHandler(() => logout()); + }, [logout]); + + useEffect(() => { + const token = localStorage.getItem('plc_token'); + if (!token) { setReady(true); return; } + api.get('/auth/me') + .then((me) => setUser(me)) + .catch(() => logout()) + .finally(() => setReady(true)); + }, [logout]); + + const login = useCallback(async (email, password) => { + const result = await api.post('/auth/login', { email, password }); + api.setToken(result.token); + setUser(result.user); + return result.user; + }, []); + + const value = useMemo(() => ({ user, ready, login, logout }), [user, ready, login, logout]); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider'); + return ctx; +} diff --git a/frontend/src/context/ui-context.jsx b/frontend/src/context/ui-context.jsx new file mode 100644 index 0000000..1f9635a --- /dev/null +++ b/frontend/src/context/ui-context.jsx @@ -0,0 +1,65 @@ +import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react'; +import { messages } from '../i18n/messages.js'; + +const UiContext = createContext(null); + +function readStored(key, fallback) { + try { + return localStorage.getItem(key) || fallback; + } catch { + return fallback; + } +} + +export function UiProvider({ children }) { + const [theme, setThemeState] = useState(() => readStored('plc_theme', 'dark')); + const [lang, setLangState] = useState(() => readStored('plc_lang', 'es-EC')); + const [buttonMode, setButtonModeState] = useState(() => readStored('plc_button_mode', 'icon-text')); + const [dialog, setDialog] = useState(null); + const [toasts, setToasts] = useState([]); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('plc_theme', theme); + }, [theme]); + + useEffect(() => { + document.documentElement.setAttribute('lang', lang); + localStorage.setItem('plc_lang', lang); + }, [lang]); + + useEffect(() => { + document.documentElement.setAttribute('data-button-mode', buttonMode); + localStorage.setItem('plc_button_mode', buttonMode); + }, [buttonMode]); + + const t = useCallback((key) => messages[lang]?.[key] ?? messages['es-EC'][key] ?? key, [lang]); + + const openDialog = useCallback((config) => setDialog(config), []); + const closeDialog = useCallback(() => setDialog(null), []); + + const pushToast = useCallback((toast) => { + const id = Date.now() + Math.random(); + setToasts((prev) => [...prev, { id, tone: 'info', ...toast }]); + setTimeout(() => setToasts((prev) => prev.filter((t2) => t2.id !== id)), 4000); + }, []); + + const value = useMemo( + () => ({ + theme, setTheme: setThemeState, + lang, setLang: setLangState, + buttonMode, setButtonMode: setButtonModeState, + t, dialog, openDialog, closeDialog, + toasts, pushToast, + }), + [theme, lang, buttonMode, t, dialog, openDialog, closeDialog, toasts, pushToast] + ); + + return {children}; +} + +export function useUi() { + const ctx = useContext(UiContext); + if (!ctx) throw new Error('useUi debe usarse dentro de UiProvider'); + return ctx; +} diff --git a/frontend/src/hooks/useAsync.js b/frontend/src/hooks/useAsync.js new file mode 100644 index 0000000..2408565 --- /dev/null +++ b/frontend/src/hooks/useAsync.js @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useUi } from '../context/ui-context.jsx'; + +// Abstrae el patron repetido loading/try-catch/error para llamadas a la API. +export function useAsync(fn, deps = []) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await fn(); + setData(result); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + useEffect(() => { reload(); }, [reload]); + + return { data, loading, error, reload, setData }; +} + +// Ejecuta una accion mostrando error via ContentDialog si falla. +export function useAction() { + const { openDialog, t } = useUi(); + return useCallback(async (fn) => { + try { + return await fn(); + } catch (err) { + openDialog({ tone: 'error', title: t('common_error'), message: err.message }); + throw err; + } + }, [openDialog, t]); +} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js new file mode 100644 index 0000000..3785d48 --- /dev/null +++ b/frontend/src/i18n/messages.js @@ -0,0 +1,270 @@ +export const messages = { + 'es-EC': { + appName: 'LinkedCorp Proyectos', + nav_dashboard: 'Panel', + nav_projects: 'Proyectos', + nav_organization: 'Organizacion', + nav_config: 'Configuracion', + login_title: 'Iniciar sesion', + login_email: 'Correo', + login_password: 'Clave', + login_submit: 'Entrar', + login_error: 'Credenciales invalidas', + logout: 'Cerrar sesion', + common_new: 'Nuevo', + common_edit: 'Editar', + common_save: 'Guardar', + common_cancel: 'Cancelar', + common_delete: 'Eliminar', + common_close: 'Cerrar', + common_search: 'Buscar...', + common_actions: 'Acciones', + common_view: 'Ver', + common_yes: 'Si', + common_no: 'No', + common_loading: 'Cargando...', + common_empty: 'No hay datos para mostrar', + common_confirm_delete_title: 'Confirmar eliminacion', + common_confirm_delete_msg: '¿Seguro que deseas eliminar este elemento? Esta accion no se puede deshacer.', + common_saved: 'Cambios guardados', + common_error: 'Ocurrio un error', + common_name: 'Nombre', + common_description: 'Descripcion', + common_status: 'Estado', + common_priority: 'Prioridad', + common_responsible: 'Responsable', + common_department: 'Departamento', + common_active: 'Activo', + common_inactive: 'Inactivo', + common_all: 'Todos', + theme_light: 'Claro', + theme_dark: 'Oscuro', + lang_es: 'Espanol', + lang_en: 'Ingles', + button_mode_icon_text: 'Icono + texto', + button_mode_icon: 'Solo icono', + button_mode_text: 'Solo texto', + + dashboard_title: 'Panel principal', + dashboard_active_projects: 'Proyectos activos', + dashboard_overdue_projects: 'Proyectos atrasados', + dashboard_due_soon_projects: 'Proximos a vencer', + dashboard_pending_activities: 'Actividades pendientes', + dashboard_overdue_activities: 'Actividades vencidas', + dashboard_today_activities: 'Para hoy', + dashboard_average_progress: 'Avance promedio', + dashboard_my_activities: 'Mis actividades pendientes', + dashboard_projects_by_status: 'Proyectos por estado', + + projects_title: 'Proyectos', + project_code: 'Codigo', + project_new: 'Nuevo proyecto', + project_objective: 'Objetivo', + project_type: 'Tipo de proyecto', + project_owner: 'Responsable', + project_start_date: 'Fecha de inicio', + project_estimated_end_date: 'Fin estimado', + project_actual_end_date: 'Fin real', + project_progress: 'Avance', + project_requesting_department: 'Departamento solicitante', + project_responsible_department: 'Departamento responsable', + project_risk: 'Riesgo', + project_notes: 'Observaciones', + project_members: 'Miembros', + project_add_member: 'Agregar miembro', + risk_low: 'Bajo', + risk_medium: 'Medio', + risk_high: 'Alto', + + tab_info: 'Info', + tab_activities: 'Actividades', + tab_gantt: 'Gantt', + tab_calendar: 'Calendario', + tab_comments: 'Comentarios', + tab_documents: 'Documentos', + + activity_new: 'Nueva actividad', + activity_code: 'Codigo', + activity_start_date: 'Fecha inicio', + activity_due_date: 'Fecha limite', + activity_actual_end_date: 'Fecha real de fin', + activity_progress: 'Avance', + activity_milestone: 'Es hito', + activity_estimated_hours: 'Horas estimadas', + activity_dependencies: 'Dependencias', + activity_add_dependency: 'Agregar dependencia', + activity_depends_on: 'Depende de', + subtasks_title: 'Subtareas', + subtask_new: 'Nueva subtarea', + subtask_done: 'Hecha', + + due_on_time: 'En tiempo', + due_warning: 'Proximo a vencer', + due_today: 'Vence hoy', + due_overdue: 'Vencido', + + org_departments: 'Departamentos', + org_users: 'Usuarios', + department_new: 'Nuevo departamento', + department_parent: 'Departamento padre', + user_new: 'Nuevo usuario', + user_email: 'Correo', + user_role: 'Rol', + user_position: 'Cargo', + user_password: 'Clave inicial', + + config_title: 'Configuracion', + config_appearance: 'Apariencia', + config_catalogs: 'Catalogos', + config_priorities: 'Prioridades', + config_project_statuses: 'Estados de proyecto', + config_activity_statuses: 'Estados de actividad', + config_project_types: 'Tipos de proyecto', + + comments_title: 'Comentarios', + comment_placeholder: 'Escribe un comentario...', + comment_send: 'Enviar', + + documents_title: 'Documentos adjuntos', + document_upload: 'Subir archivo', + document_download: 'Descargar', + + notifications_title: 'Notificaciones', + notifications_empty: 'Sin notificaciones', + notifications_mark_all_read: 'Marcar todas como leidas', + }, + 'en-US': { + appName: 'LinkedCorp Projects', + nav_dashboard: 'Dashboard', + nav_projects: 'Projects', + nav_organization: 'Organization', + nav_config: 'Settings', + login_title: 'Sign in', + login_email: 'Email', + login_password: 'Password', + login_submit: 'Sign in', + login_error: 'Invalid credentials', + logout: 'Sign out', + common_new: 'New', + common_edit: 'Edit', + common_save: 'Save', + common_cancel: 'Cancel', + common_delete: 'Delete', + common_close: 'Close', + common_search: 'Search...', + common_actions: 'Actions', + common_view: 'View', + common_yes: 'Yes', + common_no: 'No', + common_loading: 'Loading...', + common_empty: 'No data to show', + common_confirm_delete_title: 'Confirm delete', + common_confirm_delete_msg: 'Are you sure you want to delete this item? This cannot be undone.', + common_saved: 'Changes saved', + common_error: 'An error occurred', + common_name: 'Name', + common_description: 'Description', + common_status: 'Status', + common_priority: 'Priority', + common_responsible: 'Owner', + common_department: 'Department', + common_active: 'Active', + common_inactive: 'Inactive', + common_all: 'All', + theme_light: 'Light', + theme_dark: 'Dark', + lang_es: 'Spanish', + lang_en: 'English', + button_mode_icon_text: 'Icon + text', + button_mode_icon: 'Icon only', + button_mode_text: 'Text only', + + dashboard_title: 'Dashboard', + dashboard_active_projects: 'Active projects', + dashboard_overdue_projects: 'Overdue projects', + dashboard_due_soon_projects: 'Due soon', + dashboard_pending_activities: 'Pending activities', + dashboard_overdue_activities: 'Overdue activities', + dashboard_today_activities: 'Due today', + dashboard_average_progress: 'Average progress', + dashboard_my_activities: 'My pending activities', + dashboard_projects_by_status: 'Projects by status', + + projects_title: 'Projects', + project_code: 'Code', + project_new: 'New project', + project_objective: 'Objective', + project_type: 'Project type', + project_owner: 'Owner', + project_start_date: 'Start date', + project_estimated_end_date: 'Estimated end', + project_actual_end_date: 'Actual end', + project_progress: 'Progress', + project_requesting_department: 'Requesting department', + project_responsible_department: 'Responsible department', + project_risk: 'Risk', + project_notes: 'Notes', + project_members: 'Members', + project_add_member: 'Add member', + risk_low: 'Low', + risk_medium: 'Medium', + risk_high: 'High', + + tab_info: 'Info', + tab_activities: 'Activities', + tab_gantt: 'Gantt', + tab_calendar: 'Calendar', + tab_comments: 'Comments', + tab_documents: 'Documents', + + activity_new: 'New activity', + activity_code: 'Code', + activity_start_date: 'Start date', + activity_due_date: 'Due date', + activity_actual_end_date: 'Actual end date', + activity_progress: 'Progress', + activity_milestone: 'Is milestone', + activity_estimated_hours: 'Estimated hours', + activity_dependencies: 'Dependencies', + activity_add_dependency: 'Add dependency', + activity_depends_on: 'Depends on', + subtasks_title: 'Subtasks', + subtask_new: 'New subtask', + subtask_done: 'Done', + + due_on_time: 'On time', + due_warning: 'Due soon', + due_today: 'Due today', + due_overdue: 'Overdue', + + org_departments: 'Departments', + org_users: 'Users', + department_new: 'New department', + department_parent: 'Parent department', + user_new: 'New user', + user_email: 'Email', + user_role: 'Role', + user_position: 'Position', + user_password: 'Initial password', + + config_title: 'Settings', + config_appearance: 'Appearance', + config_catalogs: 'Catalogs', + config_priorities: 'Priorities', + config_project_statuses: 'Project statuses', + config_activity_statuses: 'Activity statuses', + config_project_types: 'Project types', + + comments_title: 'Comments', + comment_placeholder: 'Write a comment...', + comment_send: 'Send', + + documents_title: 'Attachments', + document_upload: 'Upload file', + document_download: 'Download', + + notifications_title: 'Notifications', + notifications_empty: 'No notifications', + notifications_mark_all_read: 'Mark all as read', + }, +}; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..721d3eb --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './app/App.jsx'; +import './styles/tokens.css'; +import './styles/app.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/frontend/src/pages/Configuracion/CatalogEditor.jsx b/frontend/src/pages/Configuracion/CatalogEditor.jsx new file mode 100644 index 0000000..816e9fd --- /dev/null +++ b/frontend/src/pages/Configuracion/CatalogEditor.jsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { Plus, Save, Trash2 } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { listCatalog, createCatalogItem, updateCatalogItem, deleteCatalogItem } from '../../services/catalogs.js'; +import { ListView } from '../../components/ListView.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { Badge } from '../../components/Badge.jsx'; + +const COLOR_OPTIONS = ['accent', 'success', 'warning', 'danger', 'muted'].map((c) => ({ value: c, label: c })); + +function emptyForm(fields) { + const form = {}; + for (const f of fields) form[f.key] = f.type === 'checkbox' ? false : ''; + return form; +} + +// Editor generico reutilizable para los 4 catalogos (prioridades, estados, tipos de proyecto). +export function CatalogEditor({ catalogKey, fields, title }) { + const { t, openDialog, pushToast } = useUi(); + const runAction = useAction(); + const { data: items, loading, reload } = useAsync(() => listCatalog(catalogKey), [catalogKey]); + const [editing, setEditing] = useState(false); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState(emptyForm(fields)); + + function startNew() { + setSelected(null); + setForm(emptyForm(fields)); + setEditing(true); + } + + function startEdit(item) { + setSelected(item); + const next = {}; + for (const f of fields) next[f.key] = item[f.key]; + setForm(next); + setEditing(true); + } + + async function save() { + await runAction(async () => { + if (selected) await updateCatalogItem(catalogKey, selected.id, form); + else await createCatalogItem(catalogKey, form); + await reload(); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + }); + } + + function confirmDelete(item) { + openDialog({ + tone: 'warning', + title: t('common_confirm_delete_title'), + message: t('common_confirm_delete_msg'), + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { + await deleteCatalogItem(catalogKey, item.id); + await reload(); + setEditing(false); + }) }, + ], + }); + } + + const columns = fields.map((f) => ({ + key: f.key, + label: f.label, + render: f.type === 'color' ? (row) => {row[f.key]} + : f.type === 'checkbox' ? (row) => (row[f.key] ? t('common_yes') : t('common_no')) + : undefined, + })); + + if (loading || !items) return
    {t('common_loading')}
    ; + + return ( +
    +
    +

    {title}

    + +
    + r.id} + onRowClick={startEdit} + rowActions={(r) => [ + { label: t('common_edit'), onClick: () => startEdit(r) }, + { label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(r) }, + ]} + /> + + {editing && ( +
    + + {fields.map((f) => ( + + {f.type === 'color' ? ( + setForm((s) => ({ ...s, [f.key]: v }))} /> + ) : f.type === 'checkbox' ? ( + + ) : ( + setForm((s) => ({ ...s, [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value }))} + /> + )} + + ))} + +
    + + +
    +
    + )} +
    + ); +} diff --git a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx new file mode 100644 index 0000000..caca1ae --- /dev/null +++ b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx @@ -0,0 +1,89 @@ +import { Palette, ListChecks, Flag, FolderKanban, Layers } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { Expander } from '../../components/Expander.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { CatalogEditor } from './CatalogEditor.jsx'; + +export default function ConfiguracionPage() { + const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi(); + + return ( +
    +

    {t('config_title')}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + ); +} diff --git a/frontend/src/pages/Dashboard/DashboardPage.jsx b/frontend/src/pages/Dashboard/DashboardPage.jsx new file mode 100644 index 0000000..06263c6 --- /dev/null +++ b/frontend/src/pages/Dashboard/DashboardPage.jsx @@ -0,0 +1,57 @@ +import { useNavigate } from 'react-router-dom'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync } from '../../hooks/useAsync.js'; +import { getSummary } from '../../services/dashboard.js'; +import { KpiCard } from '../../components/KpiCard.jsx'; +import { ProgressBar, DueBadge } from '../../components/StatusBadge.jsx'; + +export default function DashboardPage() { + const { t } = useUi(); + const navigate = useNavigate(); + const { data, loading } = useAsync(getSummary, []); + + if (loading || !data) return
    {t('common_loading')}
    ; + const { kpis, myActivities, projectsByStatus } = data; + + return ( +
    +

    {t('dashboard_title')}

    + +
    + + + + + + + +
    + +
    +

    {t('dashboard_my_activities')}

    + {myActivities.length === 0 &&
    {t('common_empty')}
    } + {myActivities.map((a) => ( +
    navigate(`/proyectos/${a.project_id}`)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)', cursor: 'pointer' }}> +
    +
    {a.name}
    +
    {a.project_name} · {a.due_date || '—'}
    +
    +
    + +
    + ))} +
    + +
    +

    {t('dashboard_projects_by_status')}

    + {projectsByStatus.map((s) => ( +
    + + {s.name} + {s.total} +
    + ))} +
    +
    + ); +} diff --git a/frontend/src/pages/Login/LoginPage.jsx b/frontend/src/pages/Login/LoginPage.jsx new file mode 100644 index 0000000..ac0e7b5 --- /dev/null +++ b/frontend/src/pages/Login/LoginPage.jsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { LogIn } from 'lucide-react'; +import { useAuth } from '../../context/auth-context.jsx'; +import { useUi } from '../../context/ui-context.jsx'; +import { Field } from '../../components/Field.jsx'; +import { Button } from '../../components/Button.jsx'; + +export default function LoginPage() { + const { login } = useAuth(); + const { t } = useUi(); + const navigate = useNavigate(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(email, password); + navigate('/', { replace: true }); + } catch { + setError(t('login_error')); + } finally { + setLoading(false); + } + } + + return ( +
    +
    +
    +
    LC
    +
    {t('login_title')}
    +
    + {error &&
    {error}
    } + + setEmail(e.target.value)} required autoFocus /> + + + setPassword(e.target.value)} required /> + + +
    +
    + ); +} diff --git a/frontend/src/pages/Organizacion/DepartmentsPanel.jsx b/frontend/src/pages/Organizacion/DepartmentsPanel.jsx new file mode 100644 index 0000000..8ac78b5 --- /dev/null +++ b/frontend/src/pages/Organizacion/DepartmentsPanel.jsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { Plus, Save, Trash2 } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { listDepartments, createDepartment, updateDepartment, deleteDepartment } from '../../services/departments.js'; +import { Treeview } from '../../components/Treeview.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; + +function flatten(nodes, acc = []) { + for (const n of nodes) { + acc.push(n); + if (n.children?.length) flatten(n.children, acc); + } + return acc; +} + +export default function DepartmentsPanel() { + const { t, openDialog, pushToast } = useUi(); + const runAction = useAction(); + const { data: tree, loading, reload } = useAsync(listDepartments, []); + const [selected, setSelected] = useState(null); + const [editing, setEditing] = useState(false); + const [form, setForm] = useState({ name: '', parentId: '' }); + + const flatOptions = tree ? flatten(tree).map((d) => ({ value: d.id, label: d.name })) : []; + + function startNew() { + setSelected(null); + setForm({ name: '', parentId: '' }); + setEditing(true); + } + + function startEdit(node) { + setSelected(node); + setForm({ name: node.name, parentId: node.parent_id || '' }); + setEditing(true); + } + + async function save() { + if (!form.name.trim()) return; + await runAction(async () => { + const payload = { name: form.name.trim(), parentId: form.parentId || null }; + if (selected) await updateDepartment(selected.id, payload); + else await createDepartment(payload); + await reload(); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + }); + } + + function confirmDelete(node) { + openDialog({ + tone: 'warning', + title: t('common_confirm_delete_title'), + message: t('common_confirm_delete_msg'), + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('common_delete'), variant: 'danger', + onClick: () => runAction(async () => { + await deleteDepartment(node.id); + await reload(); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + }), + }, + ], + }); + } + + if (loading || !tree) return
    {t('common_loading')}
    ; + + return ( +
    +
    +
    +

    {t('org_departments')}

    + +
    + +
    + + {editing && ( +
    +

    {selected ? t('common_edit') : t('department_new')}

    + + setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> + + + o.value !== selected?.id)]} + value={form.parentId} + onChange={(v) => setForm((f) => ({ ...f, parentId: v }))} + /> + +
    + + + {selected && } +
    +
    + )} +
    + ); +} diff --git a/frontend/src/pages/Organizacion/OrganizacionPage.jsx b/frontend/src/pages/Organizacion/OrganizacionPage.jsx new file mode 100644 index 0000000..df76540 --- /dev/null +++ b/frontend/src/pages/Organizacion/OrganizacionPage.jsx @@ -0,0 +1,25 @@ +import { useState } from 'react'; +import { useUi } from '../../context/ui-context.jsx'; +import { Tabs } from '../../components/Tabs.jsx'; +import DepartmentsPanel from './DepartmentsPanel.jsx'; +import UsersPanel from './UsersPanel.jsx'; + +export default function OrganizacionPage() { + const { t } = useUi(); + const [tab, setTab] = useState('departments'); + + return ( +
    +

    {t('nav_organization')}

    + + {tab === 'departments' ? : } +
    + ); +} diff --git a/frontend/src/pages/Organizacion/UsersPanel.jsx b/frontend/src/pages/Organizacion/UsersPanel.jsx new file mode 100644 index 0000000..07d3be4 --- /dev/null +++ b/frontend/src/pages/Organizacion/UsersPanel.jsx @@ -0,0 +1,143 @@ +import { useState } from 'react'; +import { Plus, Save, UserX, UserCheck } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { listUsers, listRoles, createUser, updateUser, setUserActive } from '../../services/users.js'; +import { listDepartments } from '../../services/departments.js'; +import { ListView } from '../../components/ListView.jsx'; +import { SearchInput } from '../../components/SearchInput.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { Badge } from '../../components/Badge.jsx'; + +function flatten(nodes, acc = []) { + for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); } + return acc; +} + +const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' }; + +export default function UsersPanel() { + const { t, pushToast } = useUi(); + const runAction = useAction(); + const { data: users, loading, reload } = useAsync(listUsers, []); + const { data: roles } = useAsync(listRoles, []); + const { data: deptTree } = useAsync(listDepartments, []); + + const [search, setSearch] = useState(''); + const [editing, setEditing] = useState(false); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + + const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : []; + const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name })); + + function startNew() { + setSelected(null); + setForm(EMPTY_FORM); + setEditing(true); + } + + function startEdit(user) { + setSelected(user); + setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' }); + setEditing(true); + } + + async function save() { + if (!form.name.trim() || !form.email.trim() || !form.roleId) return; + await runAction(async () => { + if (selected) { + await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null }); + } else { + if (!form.password) return; + await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password }); + } + await reload(); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + }); + } + + async function toggleActive(user) { + await runAction(async () => { + await setUserActive(user.id, !user.is_active); + await reload(); + }); + } + + const columns = [ + { key: 'name', label: t('common_name') }, + { key: 'email', label: t('user_email') }, + { key: 'role_name', label: t('user_role') }, + { key: 'department_name', label: t('common_department'), render: (r) => r.department_name || '—' }, + { key: 'is_active', label: t('common_status'), sortable: false, render: (r) => ( + {r.is_active ? t('common_active') : t('common_inactive')} + ) }, + ]; + + if (loading || !users) return
    {t('common_loading')}
    ; + + return ( +
    +
    +
    + +
    + +
    + + `${u.name} ${u.email} ${u.role_name} ${u.department_name || ''}`} + getRowKey={(u) => u.id} + onRowClick={startEdit} + rowActions={(u) => [ + { label: t('common_edit'), onClick: () => startEdit(u) }, + u.is_active + ? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) } + : { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) }, + ]} + /> + + {editing && ( +
    +

    {selected ? t('common_edit') : t('user_new')}

    + + + setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> + + + setForm((f) => ({ ...f, email: e.target.value }))} /> + + + + + setForm((f) => ({ ...f, roleId: v }))} /> + + + setForm((f) => ({ ...f, departmentId: v }))} /> + + + + + setForm((f) => ({ ...f, position: e.target.value }))} /> + + {!selected && ( + + setForm((f) => ({ ...f, password: e.target.value }))} /> + + )} + +
    + + +
    +
    + )} +
    + ); +} diff --git a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx new file mode 100644 index 0000000..79674ec --- /dev/null +++ b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx @@ -0,0 +1,222 @@ +import { useEffect, useState } from 'react'; +import { Pencil, Save, X, Plus, Trash2, Link2 } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { updateActivity, addDependency, removeDependency } from '../../services/activities.js'; +import { listSubtasks, createSubtask, setSubtaskDone, deleteSubtask } from '../../services/subtasks.js'; +import { listCatalog } from '../../services/catalogs.js'; +import { listUsers } from '../../services/users.js'; +import { Button } from '../../components/Button.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx'; +import { Tabs } from '../../components/Tabs.jsx'; +import { CommentsPanel } from '../../components/CommentsPanel.jsx'; +import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx'; + +function toForm(a) { + return { + name: a.name, description: a.description || '', responsibleUserId: a.responsible_user_id || '', + priorityId: a.priority_id, statusId: a.status_id, startDate: a.start_date || '', dueDate: a.due_date || '', + actualEndDate: a.actual_end_date || '', isMilestone: !!a.is_milestone, estimatedHours: a.estimated_hours || '', + progressPct: a.progress_pct, + }; +} + +export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, onClose }) { + const { t, pushToast } = useUi(); + const runAction = useAction(); + const { data: priorities } = useAsync(() => listCatalog('priorities'), []); + const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []); + const { data: users } = useAsync(listUsers, []); + const { data: subtasks, reload: reloadSubtasks } = useAsync(() => listSubtasks(activity.id), [activity.id]); + + const [tab, setTab] = useState('detail'); + const [editing, setEditing] = useState(false); + const [form, setForm] = useState(() => toForm(activity)); + const [newSubtask, setNewSubtask] = useState(''); + const [addingDep, setAddingDep] = useState(''); + + useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]); + + const hasSubtasks = subtasks && subtasks.length > 0; + const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name })); + const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name })); + const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name })); + const depOptions = (siblingActivities || []) + .filter((a) => a.id !== activity.id && !activity.dependencies?.some((d) => d.id === a.id)) + .map((a) => ({ value: a.id, label: `${a.code} — ${a.name}` })); + + async function save() { + await runAction(async () => { + await updateActivity(activity.id, { ...form, responsibleUserId: form.responsibleUserId || null, estimatedHours: form.estimatedHours || null }); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + onChanged(); + }); + } + + async function addSubtaskItem() { + if (!newSubtask.trim()) return; + await runAction(async () => { + await createSubtask(activity.id, { name: newSubtask.trim() }); + setNewSubtask(''); + await reloadSubtasks(); + onChanged(); + }); + } + + async function toggleSubtask(s) { + await runAction(async () => { await setSubtaskDone(s.id, !s.is_done); await reloadSubtasks(); onChanged(); }); + } + + async function removeSubtaskItem(s) { + await runAction(async () => { await deleteSubtask(s.id); await reloadSubtasks(); onChanged(); }); + } + + async function addDep() { + if (!addingDep) return; + await runAction(async () => { await addDependency(activity.id, Number(addingDep)); setAddingDep(''); onChanged(); }); + } + + async function removeDep(depId) { + await runAction(async () => { await removeDependency(activity.id, depId); onChanged(); }); + } + + return ( +
    +
    +

    {activity.code} — {activity.name}

    + +
    + + + + {tab === 'detail' && ( +
    +
    + {editing ? ( +
    + + +
    + ) : ( + + )} +
    + + {!editing ? ( +
    + + + +
    {activity.responsible_name || '—'}
    +
    +
    {activity.description || '—'}
    + +
    {activity.start_date || '—'}
    +
    {activity.due_date || '—'}
    +
    {activity.actual_end_date || '—'}
    +
    + + + {hasSubtasks && {t('subtasks_title')}} + +
    + ) : ( +
    + + setForm((f) => ({ ...f, name: e.target.value }))} /> + + +