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:
+63
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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í).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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;
|
||||
Generated
+1248
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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];
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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})`);
|
||||
});
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
File diff suppressed because one or more lines are too long
Vendored
+12
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
VITE_API_URL=/api
|
||||
@@ -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>
|
||||
Generated
+1925
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user