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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-03 08:37:58 -05:00
commit 1d22adc42e
135 changed files with 10913 additions and 0 deletions
+63
View File
@@ -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
+157
View File
@@ -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/<modulo>/`
### 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.
+79
View File
@@ -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 <token>`), 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)
+147
View File
@@ -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.
+8
View File
@@ -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.
+84
View File
@@ -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í).
+84
View File
@@ -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 `<input type="date">` 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.
+78
View File
@@ -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).
+110
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
0.1.0
+33
View File
@@ -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`
+15
View File
@@ -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
+196
View File
@@ -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;
+1248
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
+42
View File
@@ -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);
+13
View File
@@ -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,
});
+32
View File
@@ -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',
};
+58
View File
@@ -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];
+18
View File
@@ -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,
});
}
+43
View File
@@ -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 };
@@ -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);
});
@@ -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;
}
+13
View File
@@ -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;
+130
View File
@@ -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);
}
@@ -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 });
});
@@ -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]);
}
+14
View File
@@ -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;
+7
View File
@@ -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 }));
});
+16
View File
@@ -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;
}
+10
View File
@@ -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;
+18
View File
@@ -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);
});
+19
View File
@@ -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;
+36
View File
@@ -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,
},
};
}
@@ -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 });
});
@@ -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]);
}
+13
View File
@@ -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;
@@ -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);
});
@@ -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];
}
+11
View File
@@ -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;
@@ -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,
})));
});
@@ -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;
}
+11
View File
@@ -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;
@@ -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 });
});
@@ -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;
}
+13
View File
@@ -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;
@@ -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 });
}
@@ -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 });
});
@@ -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]);
}
@@ -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;
@@ -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);
@@ -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);
});
+119
View File
@@ -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);
}
+20
View File
@@ -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;
+73
View File
@@ -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;
}
@@ -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 });
});
@@ -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 };
}
+14
View File
@@ -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;
+59
View File
@@ -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);
}
+20
View File
@@ -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);
});
+54
View File
@@ -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;
}
+14
View File
@@ -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;
+41
View File
@@ -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;
}
+34
View File
@@ -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);
});
+108
View File
@@ -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);
});
+6
View File
@@ -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})`);
});
+8
View File
@@ -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]
);
}
+4
View File
@@ -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')}`;
}
+19
View File
@@ -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);
}
+18
View File
@@ -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);
}
+109
View File
@@ -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).
File diff suppressed because it is too large Load Diff
+273
View File
@@ -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 `<select>`/combo nativo del SO — no se puede
redondear ni estilar), `Textarea`, `CheckBox`, `RadioButtons`, `ToggleSwitch` (interruptor), `Slider`.
- **DatePicker/TimePicker** — calendario propio en un **flyout** anclado al campo (navegación de mes,
día de hoy marcado, selección resaltada con `accent`); igual criterio que `ComboBox`: no usar el
selector nativo del SO por falta de control visual.
- **ListView/DataGrid** — tabla con encabezados **ordenables** (clic ordena asc/desc), columna de
**selección múltiple** (checkbox), fila con **hover** y **estado seleccionado** (`accent/.14`), y
**clic derecho** que abre el menú contextual de esa fila (ver §6). Alternativa por fila sin mouse
derecho: botón `⋯` que abre el mismo menú.
- **Master-Detail (Split View)** — patrón **nativo de Windows** (Correo, Configuración, Explorador):
lista angosta a la izquierda + panel de detalle a la derecha que se actualiza **en el sitio** al
seleccionar un ítem, sin modal. Preferir sobre "detalle en modal" cuando la pantalla es
principalmente de exploración/lectura de una lista larga.
- **Menú contextual (clic derecho)** — el patrón de interacción **más característico de Windows**:
ítems con ícono + label + acelerador de teclado a la derecha (`Ctrl+E`, `Del`…), separadores,
submenú con flecha `▸` que se expande al pasar el mouse, ítem destructivo en `danger`. Debe estar
disponible en **toda fila/elemento accionable**, no solo en listados.
- **Barra de menú clásica** (`Archivo · Editar · Ver…`) — **opcional**, solo para apps de
productividad/edición que lo necesiten (no todas las apps modernas la llevan; Configuración o
Correo no la tienen). Si se usa, cada ítem abre un menú desplegable igual al contextual.
- **Flyout** — popover anclado a un control (avatar, botón), con **light-dismiss** (clic fuera o Esc
cierra). Para menús de cuenta, ayuda contextual corta o una confirmación ligada a un botón puntual.
- **InfoBar** — banner de 4 tonos (`info/success/warning/error`) embebido en el contenido, con botón
de cierre `✕`; no empuja al usuario a decidir, solo informa.
- **Toast** — notificación flotante (esquina inferior derecha), autodesaparece (~4s), apilable. Solo
para eventos de fondo — si el usuario debe decidir algo, es un `ContentDialog`, no un toast.
- **Teaching Tip** — "coach mark" anclado con flecha, para presentar una función nueva la primera vez
(onboarding); se descarta con un botón, no reaparece tras cerrarse.
- **Expander (patrón de Configuración)** — fila con ícono + título + descripción + chevron; al hacer
clic expande un cuerpo con los controles de esa sección (toggles, botones). Es el patrón estándar
para pantallas de **Configuración/Ajustes**.
- **Treeview** — árbol jerárquico estilo Explorador de Windows (`▾/▸` para expandir/colapsar,
ícono de carpeta/archivo, selección resaltada). Para categorías, carpetas o estructuras anidadas.
- **Barra de título personalizada** — reemplaza el chrome nativo de Windows: ícono+nombre de la app a
la izquierda (zona **arrastrable**, mueve la ventana), controles **minimizar / maximizar / cerrar**
a la derecha. `Cerrar` se resalta en rojo (`#C42B1C`) al hover; `Maximizar` muestra, al hover, la
vista previa de **Snap Layouts** (2/3/4 particiones) — detalle que distingue a una app "moderna".
- **NavigationView (panel de navegación)** — fijo a la izquierda, con **tres modos**: expandido
(ícono+texto), compacto (solo ícono) y oculto (drawer). Se colapsa con el botón ☰; tiene buscador
propio arriba y, al pie, la cuenta del usuario (abre un `Flyout`).
- **CommandBar** — franja de botones de acción de la vista actual (Nuevo, Actualizar, Eliminar…) con
botón **"···" (overflow)** para acciones secundarias cuando no caben todos los botones.
- **BreadcrumbBar** — ruta de navegación bajo la barra de comandos, segmentos separados por `›`, el
último en negrita y no clicable.
- **Barra de estado** — pie de ventana con texto pequeño: conteo de elementos/selección, estado de
conexión, versión. Equivalente a la barra de estado del Explorador de Windows.
- **KPI / spinner / skeleton / progress bar** — igual criterio que en web: tarjetas de indicador con
delta ▲/▼, spinner circular para cargas cortas, skeleton para listados, barra de progreso para
procesos medibles.
- **Gráficas** — Chart.js local (`chart.umd.js`, sin CDN); colores desde los tokens del tema y se
reconstruyen al cambiar tema/acento. `maintainAspectRatio:false` + contenedor con `max-height`.
- **Plantillas de ventana** — **Login** (ventana pequeña centrada, avatar, usuario/contraseña,
recordarme), **Acerca de** (ventana utilitaria pequeña con ícono, versión, botón de actualización) y
**Reporte/factura** (documento con `Imprimir`/`Exportar PDF`, aislado con `@media print` si el shell
es web-based).
---
## 4. Layout (estructura de la ventana) y NOMENCLATURA de regiones
Úsalos para pedir cambios: "cambia X en `barra-comandos`". La muestra viva rotula visualmente cada
región (chip azul) y cada componente (chip verde) al activar el interruptor "Mostrar nombres" (vista
Resumen, o menú `···` de la barra de comandos).
| Nombre | Qué es | Comportamiento |
|---|---|---|
| `ventana` | contenedor raíz de la app | esquinas redondeadas (8px), sombra; simula el marco de una app de escritorio flotando sobre el escritorio |
| `barra-titulo` | barra de título personalizada | **arrastrable** salvo sobre sus controles; ícono+nombre a la izq., minimizar/maximizar/cerrar a la der. |
| `navegacion` | panel de navegación izquierdo (NavigationView) | **FIJO**, scroll propio si hay muchos ítems; modos expandido/compacto/oculto |
| `col-derecha` | columna derecha | todo lo no-navegación, alto completo de la ventana |
| `barra-comandos` | CommandBar de la vista actual | botones de acción + overflow `···` |
| `migas-pan` | BreadcrumbBar | bajo la barra de comandos; último nivel en negrita |
| `area-contenido` | contenido de la vista | **ÚNICA** región con scroll propio |
| `panel-detalle` | panel derecho en vistas maestro-detalle | se actualiza al seleccionar un ítem, sin modal |
| `divisor` | separador arrastrable entre paneles | cursor `col-resize`; el arrastre con mouse redimensiona los paneles adyacentes |
| `barra-estado` | pie de ventana | conteo de ítems/selección, estado; texto pequeño |
**Comportamiento clave:** `navegacion` queda **fija** (no se desplaza con `area-contenido`); solo
`area-contenido` hace scroll. `barra-titulo`, `barra-comandos`, `migas-pan` y `barra-estado`
permanecen siempre visibles.
**Ventanas secundarias vs. una sola ventana:** a diferencia de la web (todo en una pestaña), una app
Windows puede abrir **ventanas propias** para diálogos de larga duración o utilitarios (Acerca de,
Propiedades, un asistente). Regla: usar una **ventana secundaria** solo cuando el contenido necesita
vivir independientemente de la ventana principal (el usuario puede moverla, alternar entre ambas con
Alt+Tab); si es una decisión puntual ligada al contexto actual, es un `ContentDialog`, no una ventana nueva.
---
## 5. Interacción — pensada primero para mouse
- **Hover en todo lo accionable**: botones, filas de listado, ítems de navegación, ítems de menú y
árbol cambian de fondo al pasar el mouse (`panel` o `accent/.14` si están seleccionados). Es la
señal principal de "esto se puede interactuar" en una app de mouse.
- **Clic derecho = menú contextual**, en cualquier fila/elemento accionable (listados, árbol,
tarjetas). Debe existir también una vía sin botón derecho (botón `⋯`) para paridad con touch/teclado.
- **Tooltips con retardo** (~350ms) en todo botón de solo-ícono y en controles cuyo propósito no sea
obvio por el texto visible; nunca sustituyen a un `ContentDialog` para respuestas del sistema.
- **Cursores con significado**: `pointer` en clicables, `col-resize`/`row-resize` en divisores
arrastrables, `move` en zonas de arrastre (barra de título), `text` en campos.
- **Anillo de foco solo para teclado** (`:focus-visible`), no al hacer clic con el mouse — evita el
"flash" de foco en cada clic que resulta ruidoso en una app mouse-first, sin sacrificar accesibilidad
por teclado (Tab/flechas siguen mostrando el anillo).
- **Snap Layouts**: al pasar el mouse sobre "Maximizar" se muestra la vista previa de particiones de
pantalla — reforzar este detalle si el framework lo expone nativamente (Windows 11 lo da gratis en
WinUI/WPF con el chrome nativo bien configurado).
- **Divisores/splitters arrastrables** entre paneles (p. ej. lista/árbol ↔ detalle) para que el usuario
ajuste el espacio a su gusto; el ancho elegido se **persiste** por vista.
- **Sin scroll horizontal en tablas** cuando sea evitable: preferir ocultar columnas secundarias o
usar `panel-detalle` para el resto de la información antes que forzar scroll-x.
---
## 6. Receta para construir una pantalla
1. **CommandBar** (`Nuevo`, `Actualizar`, acciones contextuales + overflow `···`).
2. **Elegir el patrón de datos** según el caso:
- **Listado + Master-Detail**: lista con selección simple a la izquierda/arriba, detalle **en el
sitio** a la derecha/abajo. Preferido cuando el usuario navega/lee mucho (correo, catálogos).
- **Listado + ContentDialog de detalle**: al hacer clic en una fila se abre un `ContentDialog`
(o una ventana secundaria si el contenido es largo) con el detalle y acciones (`Editar`,
`Eliminar`). Preferido cuando el detalle es ocasional y la lista debe verse completa mientras se usa.
3. **Selección múltiple + acciones por lote**: casillas en la columna izquierda de la tabla; la barra
de estado muestra "`N` de `M` seleccionados"; las acciones de lote aparecen en la `CommandBar`
solo cuando hay selección.
4. **Clic derecho en cada fila** con el menú contextual estándar: Ver detalle, Editar, Duplicar,
Exportar (submenú), separador, Eliminar (`danger`).
5. **Edición**: campos habilitados solo en modo edición; `Guardar` solo si hubo cambios reales;
acciones destructivas siempre confirmadas con `ContentDialog` (tono `warning`, Cancelar/Eliminar).
6. **Pie de listado** (`pie-listado`, igual criterio que en la identidad web): líneas por página
(5·10·20·50·Todo, **default 10**, persistente por listado), rango "Mostrando X–Y de N",
paginación con ventana deslizante de 5 números, y **alto constante** entre páginas (filas de
relleno invisibles en la última página parcial).
---
## 7. Reglas de identidad (do & don't)
- ✅ Barra de título **personalizada** (no la nativa del SO) con drag region + minimizar/maximizar/cerrar.
- ✅ Panel de navegación **fijo**, con modos expandido/compacto/oculto persistentes.
- ✅ Tema oscuro/claro **y** acento del sistema, ambos persistentes; heredar el acento del SO cuando el
framework lo permita.
- ✅ **Toda decisión** del usuario pasa por `ContentDialog`; estados no bloqueantes por `InfoBar`;
eventos de fondo por `Toast`; nada de `MessageBox`/`alert`/`confirm` nativos.
- ✅ **Clic derecho con menú contextual** en toda fila/elemento accionable — es la firma de interacción
de una app Windows mouse-first.
- ✅ `ComboBox` y `DatePicker` **propios** (redondeados, estilables); nunca los controles nativos del
SO/navegador para estos casos — igual criterio que la identidad web.
- ✅ Tipografía del sistema (**Segoe UI Variable**), sin cargar fuentes web — más estable y coherente
con el resto de Windows.
- ✅ Radios de esquina consistentes: `4px` controles, `8px` superficies elevadas y la ventana misma.
- ✅ Un solo set de íconos en toda la app (Lucide o Segoe Fluent Icons), heredando color del contexto.
- ✅ Master-Detail para listas de exploración; `ContentDialog`/ventana secundaria para detalle ocasional.
- ✅ Foco visible solo con teclado (`:focus-visible`); hover visible siempre (mouse-first).
- ❌ `MessageBox`/`alert`/`confirm` del sistema. ❌ combos/selects/calendarios nativos sin estilar.
❌ mezclar sets de íconos. ❌ apilar listado + formulario + detalle en una sola pantalla sin
separación clara. ❌ usar `Toast` para pedir una decisión al usuario.
> Para que una app "se sienta Windows moderna": misma barra de título, mismo panel de navegación,
> mismos cuatro mecanismos de aviso (`ContentDialog`/`InfoBar`/`Toast`/`Flyout`) bien diferenciados,
> clic derecho en todo lo accionable, y los mismos tokens de color/tipografía/radio. Abre
> `UI-UX-WINDOWS.html` para verlo funcionando y pide ajustes por el nombre de región/componente.
+14
View File
File diff suppressed because one or more lines are too long
+12
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=/api
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="es-EC">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>project.linkedcorp.net</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1925
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.462.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.4.3"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from '../context/auth-context.jsx';
import { UiProvider } from '../context/ui-context.jsx';
import { ContentDialogHost } from '../components/ContentDialog.jsx';
import { ToastHost } from '../components/ToastHost.jsx';
import { ProtectedRoute } from './ProtectedRoute.jsx';
import AppShell from './AppShell.jsx';
import LoginPage from '../pages/Login/LoginPage.jsx';
import DashboardPage from '../pages/Dashboard/DashboardPage.jsx';
import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx';
import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx';
import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx';
import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx';
export default function App() {
return (
<UiProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
<Route path="proyectos" element={<ProyectosPage />} />
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
<Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} />
</Route>
</Routes>
</BrowserRouter>
<ContentDialogHost />
<ToastHost />
</AuthProvider>
</UiProvider>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import { Menu, Sun, Moon, Bell, LogOut, User, Globe } from 'lucide-react';
import { NavigationView } from '../components/NavigationView.jsx';
import { Flyout, FlyoutItem, FlyoutSeparator } from '../components/Flyout.jsx';
import { useAuth } from '../context/auth-context.jsx';
import { useUi } from '../context/ui-context.jsx';
import { useAsync } from '../hooks/useAsync.js';
import { listNotifications, markAllNotificationsRead, markNotificationRead } from '../services/notifications.js';
export default function AppShell() {
const [navMode, setNavMode] = useState('expanded');
const { user, logout } = useAuth();
const { t, theme, setTheme, lang, setLang } = useUi();
const navigate = useNavigate();
const { data: notifData, reload: reloadNotifications } = useAsync(listNotifications, []);
const unread = notifData?.unread || 0;
function toggleNav() {
setNavMode((m) => (m === 'expanded' ? 'compact' : 'expanded'));
}
async function handleNotificationClick(notification) {
if (!notification.is_read) await markNotificationRead(notification.id);
await reloadNotifications();
if (notification.entity_type === 'project') navigate(`/proyectos/${notification.entity_id}`);
else if (notification.entity_type === 'activity') navigate(`/proyectos`);
}
return (
<div className="app-shell">
<NavigationView mode={navMode} />
<div className="content-column">
<div className="topbar">
<div className="topbar-left">
<button className="icon-btn" onClick={toggleNav}><Menu /></button>
</div>
<div className="topbar-right">
<Flyout trigger={({ onClick }) => (
<button className="icon-btn" onClick={onClick} style={{ position: 'relative' }}>
<Bell />
{unread > 0 && (
<span className="badge badge-danger" style={{ position: 'absolute', top: -2, right: -2, padding: '1px 5px', fontSize: '.6rem' }}>{unread}</span>
)}
</button>
)}>
<div style={{ padding: '6px 10px', fontWeight: 700, fontSize: '.78rem' }}>{t('notifications_title')}</div>
{(!notifData?.items || notifData.items.length === 0) && (
<div style={{ padding: '10px', fontSize: '.78rem', color: 'rgb(var(--muted))' }}>{t('notifications_empty')}</div>
)}
{notifData?.items?.slice(0, 8).map((n) => (
<FlyoutItem key={n.id} onClick={() => handleNotificationClick(n)}>
<span style={{ fontWeight: n.is_read ? 400 : 700 }}>{n.message}</span>
</FlyoutItem>
))}
{notifData?.items?.length > 0 && (
<>
<FlyoutSeparator />
<FlyoutItem onClick={async () => { await markAllNotificationsRead(); reloadNotifications(); }}>
{t('notifications_mark_all_read')}
</FlyoutItem>
</>
)}
</Flyout>
<button className="icon-btn" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? <Sun /> : <Moon />}
</button>
<button className="icon-btn" onClick={() => setLang(lang === 'es-EC' ? 'en-US' : 'es-EC')} title={lang}>
<Globe />
</button>
<Flyout trigger={({ onClick }) => (
<button className="icon-btn" onClick={onClick}><User /></button>
)}>
<div style={{ padding: '8px 10px' }}>
<div style={{ fontWeight: 700, fontSize: '.82rem' }}>{user?.name}</div>
<div style={{ fontSize: '.7rem', color: 'rgb(var(--muted))' }}>{user?.roleName || user?.roleCode}</div>
</div>
<FlyoutSeparator />
<FlyoutItem icon={LogOut} danger onClick={() => { logout(); navigate('/login'); }}>
{t('logout')}
</FlyoutItem>
</Flyout>
</div>
</div>
<div className="content-area">
<Outlet />
</div>
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/auth-context.jsx';
export function ProtectedRoute({ children }) {
const { user, ready } = useAuth();
if (!ready) return null;
if (!user) return <Navigate to="/login" replace />;
return children;
}
@@ -0,0 +1,58 @@
import { useRef } from 'react';
import { Upload, Download, Trash2, Paperclip } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAsync, useAction } from '../hooks/useAsync.js';
import { listAttachments, uploadAttachment, deleteAttachment, downloadAttachment } from '../services/attachments.js';
import { Button } from './Button.jsx';
export function AttachmentsPanel({ entityType, entityId }) {
const { t, openDialog } = useUi();
const runAction = useAction();
const { data: files, loading, reload } = useAsync(() => listAttachments(entityType, entityId), [entityType, entityId]);
const inputRef = useRef(null);
async function handleFile(e) {
const file = e.target.files?.[0];
if (!file) return;
await runAction(async () => {
await uploadAttachment(entityType, entityId, file);
await reload();
});
e.target.value = '';
}
function confirmDelete(file) {
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 deleteAttachment(file.id); await reload(); }) },
],
});
}
if (loading || !files) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div style={{ marginBottom: 12 }}>
<input ref={inputRef} type="file" hidden onChange={handleFile} />
<Button variant="standard" icon={Upload} onClick={() => inputRef.current?.click()}>{t('document_upload')}</Button>
</div>
{files.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
{files.map((f) => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)' }}>
<Paperclip size={15} style={{ opacity: .7 }} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: '.82rem' }}>{f.original_name}</div>
<div className="text-muted" style={{ fontSize: '.7rem' }}>{f.uploaded_by_name} · {(f.size / 1024).toFixed(0)} KB</div>
</div>
<button className="icon-btn" onClick={() => downloadAttachment(f.id, f.original_name)}><Download size={16} /></button>
<button className="icon-btn" onClick={() => confirmDelete(f)}><Trash2 size={16} /></button>
</div>
))}
</div>
);
}
+9
View File
@@ -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 <span className={`badge ${cls}`}>{children}</span>;
}
+9
View File
@@ -0,0 +1,9 @@
export function Button({ variant = 'standard', size, icon: Icon, children, className = '', ...rest }) {
const sizeClass = size === 'sm' ? 'btn-sm' : '';
return (
<button className={`btn btn-${variant} ${sizeClass} ${className}`.trim()} {...rest}>
{Icon && <Icon />}
{children && <span className="btn-label">{children}</span>}
</button>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, Search } from 'lucide-react';
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
// options: [{ value, label }]
export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const rootRef = useRef(null);
useEffect(() => {
function onDocClick(e) {
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
const selected = options.find((o) => String(o.value) === String(value));
const filtered = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options;
return (
<div className="combobox" ref={rootRef}>
<button type="button" className="input" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }} onClick={() => setOpen((v) => !v)}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
<ChevronDown size={14} />
</button>
{open && (
<div className="combobox-panel">
{options.length >= searchThreshold && (
<div className="combobox-search" style={{ position: 'relative' }}>
<Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} />
<input
autoFocus
className="input"
style={{ paddingLeft: 26 }}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
/>
</div>
)}
{filtered.length === 0 && <div className="combobox-empty">Sin resultados</div>}
{filtered.map((o) => (
<div
key={o.value}
className={`combobox-option ${String(o.value) === String(value) ? 'active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false); setQuery(''); }}
>
{o.label}
</div>
))}
</div>
)}
</div>
);
}
+40
View File
@@ -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 <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
{comments.length === 0 && <div className="text-muted" style={{ marginBottom: 12 }}>{t('common_empty')}</div>}
{comments.map((c) => (
<div key={c.id} style={{ padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)' }}>
<div style={{ fontSize: '.78rem', fontWeight: 700 }}>{c.user_name} <span className="text-muted" style={{ fontWeight: 400 }}>· {c.created_at}</span></div>
<div style={{ fontSize: '.82rem', marginTop: 2 }}>{c.message}</div>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="input" value={message} placeholder={t('comment_placeholder')} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} />
<Button variant="accent" icon={Send} onClick={send}>{t('comment_send')}</Button>
</div>
</div>
);
}
+38
View File
@@ -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 (
<div className="dialog-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) closeDialog(); }}>
<div className="dialog-surface">
<div className="dialog-stripe" style={{ background: `rgb(var(--${STRIPE_COLOR[tone]}))` }} />
<div className="dialog-body">
<div className="dialog-title">{dialog.title}</div>
<div className="dialog-message">{dialog.message}</div>
</div>
<div className="dialog-actions">
{actions.map((action, i) => (
<Button key={i} variant={action.variant || 'standard'} onClick={() => handleClick(action)}>
{action.label}
</Button>
))}
</div>
</div>
</div>
);
}
+19
View File
@@ -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 (
<div className={`expander ${open ? 'open' : ''}`}>
<div className="expander-header" onClick={() => setOpen((v) => !v)}>
{Icon && <Icon size={18} />}
<div>
<div className="expander-title">{title}</div>
{description && <div className="expander-desc">{description}</div>}
</div>
<ChevronRight className="chevron" size={16} />
</div>
{open && <div className="expander-body">{children}</div>}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export function Field({ label, children, hint }) {
return (
<div className="field">
{label && <label className="field-label">{label}</label>}
{children}
{hint && <span className="text-muted" style={{ fontSize: '.72rem' }}>{hint}</span>}
</div>
);
}
export function FieldRow({ children }) {
return <div className="field-row">{children}</div>;
}
+57
View File
@@ -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 (
<>
<span ref={anchorRef}>{trigger({ onClick: () => (open ? setOpen(false) : openFlyout()) })}</span>
{open && createPortal(
<>
<div className="flyout-backdrop" onClick={() => setOpen(false)} />
<div
className="flyout-panel"
style={{ top: pos.top, left: align === 'end' ? pos.left - 220 : pos.left }}
onClick={() => setOpen(false)}
>
{children}
</div>
</>,
document.body
)}
</>
);
}
export function FlyoutItem({ icon: Icon, danger, children, ...rest }) {
return (
<button className={`flyout-item ${danger ? 'danger' : ''}`} {...rest}>
{Icon && <Icon size={15} />}
<span>{children}</span>
</button>
);
}
export function FlyoutSeparator() {
return <div className="flyout-separator" />;
}
+21
View File
@@ -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 (
<div className={`infobar infobar-${tone}`}>
<Icon />
<div>{children}</div>
{dismissible && (
<button className="infobar-close" onClick={() => setVisible(false)}>
<X size={14} />
</button>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export function KpiCard({ label, value }) {
return (
<div className="kpi-card">
<div className="kpi-value">{value}</div>
<div className="kpi-label">{label}</div>
</div>
);
}
+72
View File
@@ -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 (
<div className="list-card">
<table className="list-table">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} onClick={() => col.sortable !== false && toggleSort(col.key)}>
{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}
</th>
))}
{rowActions && <th style={{ width: 40 }} />}
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr key={getRowKey(row)} onClick={() => onRowClick?.(row)}>
{columns.map((col) => (
<td key={col.key}>{col.render ? col.render(row) : row[col.key]}</td>
))}
{rowActions && (
<td onClick={(e) => e.stopPropagation()}>
<Flyout trigger={({ onClick }) => (
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical /></button>
)}>
{rowActions(row).map((action, i) => (
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
{action.label}
</FlyoutItem>
))}
</Flyout>
</td>
)}
</tr>
))}
</tbody>
</table>
{sorted.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
</div>
);
}
@@ -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 (
<nav className={`nav-view ${mode}`}>
<div className="nav-header">
<div className="nav-logo">LC</div>
{mode !== 'compact' && <div className="nav-title">{t('appName')}</div>}
</div>
<div className="nav-items">
{ITEMS.map((item) => (
<NavLink key={item.to} to={item.to} end={item.end} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<item.icon />
{mode !== 'compact' && <span>{t(item.labelKey)}</span>}
</NavLink>
))}
</div>
{mode !== 'compact' && <div className="nav-footer">v0.1.0</div>}
</nav>
);
}
+12
View File
@@ -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 (
<div className="search-input">
<Search />
<input className="input" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder || t('common_search')} />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Badge } from './Badge.jsx';
import { useUi } from '../context/ui-context.jsx';
export function StatusBadge({ name, color }) {
return <Badge color={color}>{name}</Badge>;
}
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 <Badge color={DUE_COLOR[status]}>{t(DUE_KEY[status])}</Badge>;
}
export function ProgressBar({ value }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="progress-bar" style={{ flex: 1 }}>
<div className="progress-bar-fill" style={{ width: `${value}%` }} />
</div>
<span style={{ fontSize: '.72rem', width: 36, textAlign: 'right' }}>{Math.round(value)}%</span>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export function Tabs({ tabs, active, onChange }) {
return (
<div className="tabs">
{tabs.map((tab) => (
<button key={tab.key} className={`tab-item ${active === tab.key ? 'active' : ''}`} onClick={() => onChange(tab.key)}>
{tab.label}
</button>
))}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { useUi } from '../context/ui-context.jsx';
export function ToastHost() {
const { toasts } = useUi();
if (!toasts.length) return null;
return (
<div className="toast-stack">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.tone}`}>{toast.message}</div>
))}
</div>
);
}
+39
View File
@@ -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 (
<div>
<div
className="tree-item-row"
style={{ paddingLeft: level * 18 + 6, background: selectedId === node.id ? 'rgb(var(--accent) / .14)' : undefined }}
onClick={() => onSelect?.(node)}
>
{hasChildren ? (
<button className="tree-toggle" onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span className="tree-toggle" />
)}
<Folder size={15} style={{ opacity: .7 }} />
<span>{node.name}</span>
</div>
{hasChildren && open && node.children.map((child) => (
<TreeNode key={child.id} node={child} level={level + 1} onSelect={onSelect} selectedId={selectedId} />
))}
</div>
);
}
export function Treeview({ nodes, onSelect, selectedId }) {
return (
<div>
{nodes.map((node) => (
<TreeNode key={node.id} node={node} level={0} onSelect={onSelect} selectedId={selectedId} />
))}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More