feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -113,7 +113,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
|||||||
(se borra). Solo el usuario más antiguo del sistema es `superadmin` automático (bootstrap, sin
|
(se borra). Solo el usuario más antiguo del sistema es `superadmin` automático (bootstrap, sin
|
||||||
correo ni aprobación — no hay quién lo apruebe). El admin sigue pudiendo crear usuarios
|
correo ni aprobación — no hay quién lo apruebe). El admin sigue pudiendo crear usuarios
|
||||||
directamente (ya activos, sin pasar por este flujo) desde Organización → Usuarios.
|
directamente (ya activos, sin pasar por este flujo) desde Organización → Usuarios.
|
||||||
- `401` → el frontend limpia sesión, redirige a login y muestra aviso (ContentDialog).
|
- `401` o JWT vencido (detectado también sin peticiones y al volver a la pestaña) → el frontend corta la sesión, va a login con aviso y, tras entrar el mismo usuario, vuelve a la ruta/pestaña/edición donde estaba (ver `docs/SESION-Y-BORRADORES.md`).
|
||||||
- Cierre de sesión por expiración de JWT.
|
- Cierre de sesión por expiración de JWT.
|
||||||
- Auditoría en BD (`audit_log`): usuario, acción, entidad, fecha/hora, detalle.
|
- 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. Excepción:
|
- Credenciales/secretos solo en `.env` (nunca en código); `.env.example` sí se versiona. Excepción:
|
||||||
@@ -164,6 +164,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
|||||||
- `README.md`, `SPEC.md`, `BASE_DE_DATOS.md`, `CONFIGURACION.md`, `API.md`, `VERSIONADO.md`
|
- `README.md`, `SPEC.md`, `BASE_DE_DATOS.md`, `CONFIGURACION.md`, `API.md`, `VERSIONADO.md`
|
||||||
- `PRODUCCION.md`, `DEPLOY_WEB.md` (topología y flujo de deploy — arquetipo B, en producción)
|
- `PRODUCCION.md`, `DEPLOY_WEB.md` (topología y flujo de deploy — arquetipo B, en producción)
|
||||||
- `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md`, `docs/ARQUITECTURA-INVENTARIO.md`
|
- `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md`, `docs/ARQUITECTURA-INVENTARIO.md`
|
||||||
|
- `docs/SESION-Y-BORRADORES.md` (vencimiento de sesión, retorno al contexto, borradores y avisos al salir; usar `useDraftForm` en todo formulario nuevo)
|
||||||
- `ESTADO.md` (handoff — leer siempre al retomar el proyecto)
|
- `ESTADO.md` (handoff — leer siempre al retomar el proyecto)
|
||||||
|
|
||||||
## Pendientes para quedar listo
|
## Pendientes para quedar listo
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
- Tipo: Bearer JWT (`Authorization: Bearer <token>`), emitido por `/api/auth/login`.
|
- 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).
|
- El backend revalida `is_active` del usuario en cada request (no solo la firma del JWT).
|
||||||
|
- El JWT incluye `sv` (versión de sesión). Cambiar o restablecer la clave revoca los JWT
|
||||||
|
anteriores; se responde `401` y el usuario vuelve a iniciar sesión.
|
||||||
|
|
||||||
## Formato de respuesta
|
## Formato de respuesta
|
||||||
|
|
||||||
@@ -20,6 +22,14 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Edición concurrente
|
||||||
|
|
||||||
|
Los `PUT` de proyectos, comentarios, actividades, tareas, departamentos, usuarios, los cuatro
|
||||||
|
catálogos, notas, tareas recurrentes, equipos y SMTP exigen `editVersion` (entero positivo)
|
||||||
|
obtenido de la lectura correspondiente como `edit_version`. El `PUT /api/company` exige el mismo
|
||||||
|
campo en multipart. La API responde `428` si falta o es inválido y `409` si otro guardado cambió
|
||||||
|
el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
||||||
|
|
||||||
## Endpoints principales
|
## Endpoints principales
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
@@ -45,16 +55,19 @@
|
|||||||
|
|
||||||
### Usuarios (`/api/users`) — lectura para cualquier autenticado; escritura solo Administrador/Superadministrador
|
### Usuarios (`/api/users`) — lectura para cualquier autenticado; escritura solo Administrador/Superadministrador
|
||||||
- `GET /api/users` · `POST /api/users` · `PUT /api/users/:id` · `PATCH /api/users/:id/active`
|
- `GET /api/users` · `POST /api/users` · `PUT /api/users/:id` · `PATCH /api/users/:id/active`
|
||||||
- `POST /api/users/:id/approve` — `{ roleId?, departmentId? }`, solo sobre usuarios `pending_approval`
|
- `POST /api/users/:id/approve` — `{ roleId?, departmentId?, editVersion }`, solo sobre usuarios `pending_approval`; también rechaza cambios concurrentes con `409`.
|
||||||
- `POST /api/users/:id/reject` — borra al usuario `pending_approval` (se audita el email antes)
|
- `POST /api/users/:id/reject` — borra al usuario `pending_approval` (se audita el email antes)
|
||||||
|
|
||||||
### Departamentos (`/api/departments`)
|
### Departamentos (`/api/departments`)
|
||||||
- `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id`
|
- `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id`
|
||||||
|
|
||||||
### Proyectos (`/api/projects`)
|
### Proyectos (`/api/projects`)
|
||||||
- `GET /api/projects` (filtros: estado, responsable, prioridad; `trash=true` = papelera, solo
|
- `GET /api/projects` (filtros: `statusId`, `priorityId`, `ownerUserId`, `dueSoon=true`,
|
||||||
superadmin) · `POST /api/projects`
|
`progress=hide|only`, `q`, `trash=true` solo superadmin; `page` desde 1 y `pageSize` 1–100,
|
||||||
- `GET /api/projects/:id` · `PUT /api/projects/:id`
|
predeterminado 20) → `{ items, total, page, pageSize }` · `POST /api/projects`.
|
||||||
|
- `GET /api/projects/options` → `{ id, code, name }[]` accesibles al usuario para selectores.
|
||||||
|
- `GET /api/projects/:id` · `PUT /api/projects/:id` — para editar, enviar `editVersion` obtenido
|
||||||
|
de `GET`; `428` sin versión, `409` si otra edición la cambió.
|
||||||
- `DELETE /api/projects/:id` — eliminación lógica (`deleted_at`/`deleted_by`), mismo permiso que
|
- `DELETE /api/projects/:id` — eliminación lógica (`deleted_at`/`deleted_by`), mismo permiso que
|
||||||
editar el proyecto. `POST /api/projects/:id/restore` y `DELETE /api/projects/:id/permanent`
|
editar el proyecto. `POST /api/projects/:id/restore` y `DELETE /api/projects/:id/permanent`
|
||||||
(borrado físico, irreversible, arrastra actividades/comentarios/adjuntos) — ambos solo
|
(borrado físico, irreversible, arrastra actividades/comentarios/adjuntos) — ambos solo
|
||||||
@@ -107,21 +120,24 @@
|
|||||||
(`last_reminder_sent_at`, se limpia al completar el ciclo).
|
(`last_reminder_sent_at`, se limpia al completar el ciclo).
|
||||||
|
|
||||||
### Comentarios (`/api/comments`)
|
### Comentarios (`/api/comments`)
|
||||||
|
|
||||||
|
- `PUT /api/comments/:id` — `{ message, editVersion }`; `428` sin versión y `409` si cambió.
|
||||||
- `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments`
|
- `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments`
|
||||||
|
|
||||||
### Adjuntos (`/api/attachments`)
|
### Adjuntos (`/api/attachments`)
|
||||||
- `GET /api/attachments?entityType=activity&entityId=1` · `POST /api/attachments` (multipart)
|
- `GET /api/attachments?entityType=activity&entityId=1` · `POST /api/attachments?entityType=activity&entityId=1`
|
||||||
|
(multipart; entidad y permiso se validan antes de recibir el archivo)
|
||||||
- `GET /api/attachments/:id/download` · `DELETE /api/attachments/:id`
|
- `GET /api/attachments/:id/download` · `DELETE /api/attachments/:id`
|
||||||
|
|
||||||
### Notas (`/api/notes`)
|
### Notas (`/api/notes`)
|
||||||
- `GET /api/notes` — solo las del usuario autenticado, ordenadas por `updated_at` descendente.
|
- `GET /api/notes` — solo las del usuario autenticado, ordenadas por `updated_at` descendente.
|
||||||
- `POST /api/notes` — `{ content }`. `PUT /api/notes/:id` — `{ content }`. `DELETE /api/notes/:id`.
|
- `POST /api/notes` — `{ content }`. `PUT /api/notes/:id` — `{ content, editVersion }`. `DELETE /api/notes/:id`.
|
||||||
- Siempre privadas: una nota de otro usuario responde `404` (no `403`, para no revelar que
|
- Siempre privadas: una nota de otro usuario responde `404` (no `403`, para no revelar que
|
||||||
existe). Sin restricción de rol — cualquier autenticado tiene las suyas.
|
existe). Sin restricción de rol — cualquier autenticado tiene las suyas.
|
||||||
|
|
||||||
### Equipos (`/api/devices`) — superadmin / admin / it_manager (tokens: solo superadmin / admin)
|
### Equipos (`/api/devices`) — superadmin / admin / it_manager (tokens: solo superadmin / admin)
|
||||||
- `GET /api/devices?q=&kind=&status=&stale=1&diskFull=1` · `GET /api/devices/:id` (con hardware,
|
- `GET /api/devices?q=&kind=&status=&stale=1&diskFull=1` · `GET /api/devices/:id` (con hardware,
|
||||||
red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes }`.
|
red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes, editVersion }`.
|
||||||
- `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` ·
|
- `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` ·
|
||||||
`GET /api/devices/software/devices?name=` — software transversal.
|
`GET /api/devices/software/devices?name=` — software transversal.
|
||||||
- `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de
|
- `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de
|
||||||
@@ -184,8 +200,8 @@
|
|||||||
- `GET /api/audit?entityType=project&entityId=1`
|
- `GET /api/audit?entityType=project&entityId=1`
|
||||||
|
|
||||||
### Configuración SMTP (`/api/settings`) — solo Superadministrador
|
### Configuración SMTP (`/api/settings`) — solo Superadministrador
|
||||||
- `GET /api/settings/smtp` — `{ host, port, user, from, configured }` (nunca la clave)
|
- `GET /api/settings/smtp` — `{ host, port, user, from, configured, edit_version }` (nunca la clave)
|
||||||
- `PUT /api/settings/smtp` — `{ host, port, user, from, pass? }` (`pass` vacío = no cambiarla)
|
- `PUT /api/settings/smtp` — `{ host, port, user, from, pass?, editVersion }` (`pass` vacío = no cambiarla)
|
||||||
- `POST /api/settings/smtp/test` — `{ host, port, user, from, pass?, to }` (`pass` vacío = usa la
|
- `POST /api/settings/smtp/test` — `{ host, port, user, from, pass?, to }` (`pass` vacío = usa la
|
||||||
guardada). Envía un correo real con los valores del formulario (aunque no se hayan guardado) y
|
guardada). Envía un correo real con los valores del formulario (aunque no se hayan guardado) y
|
||||||
responde `{ sent: true }` o `400` con el error real del servidor SMTP. Rate-limited (10/15min).
|
responde `{ sent: true }` o `400` con el error real del servidor SMTP. Rate-limited (10/15min).
|
||||||
@@ -195,8 +211,8 @@
|
|||||||
logoVersion }`; `name` por defecto "LinkedCorp" si nunca se configuró.
|
logoVersion }`; `name` por defecto "LinkedCorp" si nunca se configuró.
|
||||||
- `GET /api/company/logo` — público. Devuelve el archivo de imagen del logo, o `404` si no hay
|
- `GET /api/company/logo` — público. Devuelve el archivo de imagen del logo, o `404` si no hay
|
||||||
ninguno configurado (el cliente cae al logo estático embebido).
|
ninguno configurado (el cliente cae al logo estático embebido).
|
||||||
- `PUT /api/company` — solo Superadministrador, multipart: `name?` (texto) y `logo?` (archivo,
|
- `PUT /api/company` — solo Superadministrador, multipart: `name?` (texto), `editVersion` y `logo?` (archivo,
|
||||||
PNG/JPG/WEBP/SVG, máx 3MB). Al subir un logo nuevo se borra el archivo anterior (nunca quedan
|
PNG/JPG/WEBP, máx 3MB; se comprueba la firma real del archivo). Al subir un logo nuevo se borra el archivo anterior (nunca quedan
|
||||||
huérfanos). Guardado en `system_settings` (mismo patrón que SMTP), sin tabla nueva.
|
huérfanos). Guardado en `system_settings` (mismo patrón que SMTP), sin tabla nueva.
|
||||||
|
|
||||||
## Errores esperados
|
## Errores esperados
|
||||||
|
|||||||
@@ -70,6 +70,13 @@
|
|||||||
editables directo por el cliente: actividad con tareas = % de tareas hechas; actividad sin
|
editables directo por el cliente: actividad con tareas = % de tareas hechas; actividad sin
|
||||||
tareas = manual (0-100); proyecto = promedio del avance de sus actividades.
|
tareas = manual (0-100); proyecto = promedio del avance de sus actividades.
|
||||||
- `users.password_hash` con bcrypt, nunca texto plano ni reversible.
|
- `users.password_hash` con bcrypt, nunca texto plano ni reversible.
|
||||||
|
- `users.session_version` (migración `028`) comienza en 1 y aumenta al cambiar/restablecer clave;
|
||||||
|
cada JWT de sesión debe tener la misma versión. Aplicar la migración antes de iniciar el backend nuevo.
|
||||||
|
- Migración `029`: `edit_version` comienza en 1 en proyectos, comentarios, actividades, tareas,
|
||||||
|
departamentos, usuarios, catálogos, notas, tareas recurrentes y equipos. El `UPDATE` de edición
|
||||||
|
verifica la versión y la incrementa atómicamente; sin coincidencia responde 409. En
|
||||||
|
`system_settings`, las claves `company_edit_version` y `smtp_edit_version` serializan los
|
||||||
|
guardados de cada panel dentro de una transacción. Aplicar 029 antes de iniciar el backend nuevo.
|
||||||
- `users.status` (`pending_verification`/`pending_approval`/`active`/`invited`) es el ciclo de vida
|
- `users.status` (`pending_verification`/`pending_approval`/`active`/`invited`) es el ciclo de vida
|
||||||
de la cuenta, independiente de `is_active` (que sigue siendo el interruptor manual del admin
|
de la cuenta, independiente de `is_active` (que sigue siendo el interruptor manual del admin
|
||||||
para desactivar a alguien ya activo). Un usuario `pending_approval` rechazado se **borra**
|
para desactivar a alguien ya activo). Un usuario `pending_approval` rechazado se **borra**
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# Changelog - project.linkedcorp.net
|
||||||
|
|
||||||
|
## [3.0.0] - 2026-10-23
|
||||||
|
### Security
|
||||||
|
- Se restringe la asignación de superadmin, la escritura de consultores y la búsqueda/tareas
|
||||||
|
recurrentes por alcance. El primer registro es atómico. Cambio/restablecimiento de clave
|
||||||
|
revoca JWT previos (migración 028).
|
||||||
|
- Logo público solo PNG/JPG/WEBP con firma comprobada y CSP; se censura `?token=` en logs de la
|
||||||
|
aplicación; los adjuntos comprueban permisos antes de recibir archivos y limpian fallos.
|
||||||
|
- La ingesta valida token antes del cuerpo, limita frecuencia/concurrencia y parsea en worker.
|
||||||
|
El vínculo token-equipo queda pendiente para no romper agentes con token compartido.
|
||||||
|
### Changed
|
||||||
|
- `GET /api/projects` devuelve páginas; frontend y selectores adaptados. La edición de proyectos
|
||||||
|
y comentarios exige versión optimista (migración 029), con rechazo 409 al competir.
|
||||||
|
- El control optimista se extiende a actividades, tareas, departamentos, usuarios, catálogos,
|
||||||
|
notas, tareas recurrentes, equipos, empresa y SMTP. Los formularios conservan la versión
|
||||||
|
del borrador y muestran el aviso de conflicto al recibir 409.
|
||||||
|
- Correos HTML escapados y recordatorios reintentables tras fallo SMTP; borradores limitados a
|
||||||
|
campos permitidos por formulario y limpiados al cerrar sesión voluntariamente.
|
||||||
|
- Diálogos, controles de lista/combo/expansor, pestañas e i18n mejorados para teclado y lectores
|
||||||
|
de pantalla.
|
||||||
|
### Deployment
|
||||||
|
- Aplicar migraciones 028 y 029 antes del backend de esta versión. Los JWT existentes caducarán;
|
||||||
|
clientes externos de proyectos/adjuntos deben adoptar los contratos descritos en `API.md`.
|
||||||
|
|
||||||
## [2.19.0] - 2026-10-21
|
## [2.19.0] - 2026-10-21
|
||||||
### Added
|
### Added
|
||||||
- Panel: selector **Tarjetas (n/7)** junto al título, con una casilla por tarjeta para
|
- Panel: selector **Tarjetas (n/7)** junto al título, con una casilla por tarjeta para
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
# ESTADO — project.linkedcorp.net
|
# ESTADO — project.linkedcorp.net
|
||||||
|
|
||||||
Ultima actualizacion: 2026-10-19 (v2.15.0 en desarrollo, sin desplegar)
|
Ultima actualizacion: 2026-10-23 (v3.0.0 en desarrollo, sin desplegar)
|
||||||
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
|
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
|
||||||
autorregistro/aprobacion/SMTP.
|
autorregistro/aprobacion/SMTP.
|
||||||
|
|
||||||
|
## Auditoría de seguridad y calidad (v3.0.0, sin desplegar)
|
||||||
|
|
||||||
|
- Se corrigieron controles de roles, alcance de búsqueda y tareas recurrentes, bootstrap
|
||||||
|
concurrente, subida de logo/adjuntos, censura de tokens en logs de la app, revocación JWT,
|
||||||
|
límites de ingesta, correo, paginación y accesibilidad base.
|
||||||
|
- Migraciones nuevas `028_session_version.sql` y `029_optimistic_versions.sql`: **no aplicadas**.
|
||||||
|
El backend nuevo requiere ambas antes de arrancar con una base existente. Tras `028`, los JWT
|
||||||
|
anteriores dejan de funcionar y se debe iniciar sesión otra vez.
|
||||||
|
- `GET /api/projects` ahora devuelve `{items,total,page,pageSize}`. El frontend de esta versión
|
||||||
|
se adaptó; otros clientes deben hacerlo. `POST /api/attachments` requiere entidad en query.
|
||||||
|
- Pendiente: vinculación token de inventario a equipo (instaladores actuales comparten token),
|
||||||
|
medición SQL `EXPLAIN` con datos reales. Hallazgo 7: falta verificar los logs del proxy NPM
|
||||||
|
para confirmar la censura de tokens; pendiente operativo, sin tocar NPM. Hallazgo 11:
|
||||||
|
vincular token de inventario a equipo requiere migrar las instalaciones que comparten token;
|
||||||
|
pendiente operativo. No se consultó la BD ni producción durante esta tarea.
|
||||||
|
- Hallazgo 16 completo en código: edición con versión optimista para proyectos, comentarios,
|
||||||
|
actividades, tareas, departamentos, usuarios, los cuatro catálogos, notas, tareas recurrentes,
|
||||||
|
equipos, empresa y SMTP. La migración 029 se amplió; sigue **sin aplicar**.
|
||||||
|
|
||||||
## Como retomar
|
## Como retomar
|
||||||
|
|
||||||
1. Leer primero el orquestador: `/home/mgonzalez/desarrollo/project-md-templates/leeme_primero.txt`.
|
1. Leer primero el orquestador: `/home/mgonzalez/desarrollo/project-md-templates/leeme_primero.txt`.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Una version por cuenta invalida todos los JWT anteriores al cambiar la clave.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS session_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE comments ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE activities ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE departments ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE project_types ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE priorities ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE project_statuses ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE activity_statuses ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE notes ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE recurring_tasks ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE devices ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1;
|
||||||
|
INSERT INTO system_settings (`key`, `value`) VALUES ('company_edit_version', '1'), ('smtp_edit_version', '1') ON DUPLICATE KEY UPDATE `value` = `value`;
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.14.0",
|
"version": "3.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.14.0",
|
"version": "3.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.19.0",
|
"version": "3.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
+5
-1
@@ -34,7 +34,11 @@ export const app = express();
|
|||||||
// de uno por usuario) y ademas tira un ValidationError que ensucia los logs en cada request.
|
// de uno por usuario) y ademas tira un ValidationError que ensucia los logs en cada request.
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
app.use(helmet({ contentSecurityPolicy: false }));
|
app.use(helmet({ contentSecurityPolicy: { directives: {
|
||||||
|
defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||||
|
fontSrc: ["'self'", 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'blob:'],
|
||||||
|
connectSrc: ["'self'"], objectSrc: ["'none'"], baseUri: ["'self'"], frameAncestors: ["'none'"],
|
||||||
|
} } }));
|
||||||
app.use(cors({ origin: env.corsOrigin, credentials: true, exposedHeaders: ['X-Vault-Token'] }));
|
app.use(cors({ origin: env.corsOrigin, credentials: true, exposedHeaders: ['X-Vault-Token'] }));
|
||||||
// Ingesta del agente GLPI: ANTES del parser JSON (usa cuerpo crudo, sin CORS: la llama el agente).
|
// Ingesta del agente GLPI: ANTES del parser JSON (usa cuerpo crudo, sin CORS: la llama el agente).
|
||||||
app.use(inventoryIngestRoutes);
|
app.use(inventoryIngestRoutes);
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ export async function requireAuth(req, res, next) {
|
|||||||
|
|
||||||
// Revalidar en cada request que la cuenta siga activa (un JWT firmado no basta).
|
// Revalidar en cada request que la cuenta siga activa (un JWT firmado no basta).
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT u.id, u.name, u.email, u.is_active, u.department_id, r.code AS role_code
|
`SELECT u.id, u.name, u.email, u.is_active, u.session_version, u.department_id, r.code AS role_code
|
||||||
FROM users u JOIN roles r ON r.id = u.role_id
|
FROM users u JOIN roles r ON r.id = u.role_id
|
||||||
WHERE u.id = ? LIMIT 1`,
|
WHERE u.id = ? LIMIT 1`,
|
||||||
[payload.sub]
|
[payload.sub]
|
||||||
);
|
);
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
if (!user || !user.is_active) return fail(res, 401, 'Cuenta inactiva o inexistente');
|
if (!user || !user.is_active || payload.sv !== user.session_version) return fail(res, 401, 'Sesion invalida o revocada');
|
||||||
|
|
||||||
req.currentUser = {
|
req.currentUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -59,3 +59,5 @@ export const CAN_MANAGE_USERS = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
|||||||
export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN];
|
export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN];
|
||||||
export const CAN_MANAGE_SETTINGS = [ROLES.SUPERADMIN];
|
export const CAN_MANAGE_SETTINGS = [ROLES.SUPERADMIN];
|
||||||
export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
|
export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
|
||||||
|
export const CAN_EDIT_DEVICES = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
|
||||||
|
export const CAN_MANAGE_DEVICES = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import crypto from 'node:crypto';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']);
|
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||||
|
|
||||||
export const logoDir = path.join(env.storageRoot, 'branding');
|
export const logoDir = path.join(env.storageRoot, 'branding');
|
||||||
fs.mkdirSync(logoDir, { recursive: true });
|
fs.mkdirSync(logoDir, { recursive: true });
|
||||||
@@ -24,7 +24,7 @@ export const uploadLogo = multer({
|
|||||||
limits: { fileSize: 3 * 1024 * 1024 },
|
limits: { fileSize: 3 * 1024 * 1024 },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (!ALLOWED_MIME.has(file.mimetype)) {
|
if (!ALLOWED_MIME.has(file.mimetype)) {
|
||||||
return cb(new Error('Tipo de archivo no permitido (usa PNG, JPG, WEBP o SVG)'));
|
return cb(new Error('Tipo de archivo no permitido (usa PNG, JPG o WEBP)'));
|
||||||
}
|
}
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const LIST_SELECT = `
|
|||||||
a.priority_id, pr.name AS priority_name, pr.color AS priority_color,
|
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.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.start_date, a.due_date, a.actual_end_date, a.progress_pct, a.is_milestone,
|
||||||
a.estimated_hours, a.sort_order, a.created_by, a.created_at, a.updated_at,
|
a.estimated_hours, a.edit_version, a.sort_order, a.created_by, a.created_at, a.updated_at,
|
||||||
a.deleted_at, a.deleted_by,
|
a.deleted_at, a.deleted_by,
|
||||||
(SELECT COUNT(*) FROM activity_dependencies d WHERE d.activity_id = a.id) AS dependencies_count,
|
(SELECT COUNT(*) FROM activity_dependencies d WHERE d.activity_id = a.id) AS dependencies_count,
|
||||||
(SELECT COUNT(*) FROM comments c WHERE c.entity_type = 'activity' AND c.entity_id = a.id) AS comments_count,
|
(SELECT COUNT(*) FROM comments c WHERE c.entity_type = 'activity' AND c.entity_id = a.id) AS comments_count,
|
||||||
@@ -62,18 +62,18 @@ export async function create(projectId, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id, data) {
|
export async function update(id, data) {
|
||||||
await pool.query(
|
const [result] = await pool.query(
|
||||||
`UPDATE activities SET
|
`UPDATE activities SET
|
||||||
name = ?, description = ?, responsible_user_id = ?, priority_id = ?, status_id = ?,
|
name = ?, description = ?, responsible_user_id = ?, priority_id = ?, status_id = ?,
|
||||||
start_date = ?, due_date = ?, actual_end_date = ?, is_milestone = ?, estimated_hours = ?
|
start_date = ?, due_date = ?, actual_end_date = ?, is_milestone = ?, estimated_hours = ?, edit_version = edit_version + 1
|
||||||
WHERE id = ?`,
|
WHERE id = ? AND edit_version = ? AND deleted_at IS NULL`,
|
||||||
[
|
[
|
||||||
data.name, data.description || null, data.responsibleUserId || null, data.priorityId, data.statusId,
|
data.name, data.description || null, data.responsibleUserId || null, data.priorityId, data.statusId,
|
||||||
data.startDate || null, data.dueDate || null, data.actualEndDate || null,
|
data.startDate || null, data.dueDate || null, data.actualEndDate || null,
|
||||||
data.isMilestone ? 1 : 0, data.estimatedHours || null, id,
|
data.isMilestone ? 1 : 0, data.estimatedHours || null, id, data.editVersion,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return findById(id);
|
return result.affectedRows ? findById(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProgress(id, progressPct) {
|
export async function updateProgress(id, progressPct) {
|
||||||
@@ -81,11 +81,11 @@ export async function updateProgress(id, progressPct) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function softDelete(id, userId) {
|
export async function softDelete(id, userId) {
|
||||||
await pool.query('UPDATE activities SET deleted_at = NOW(), deleted_by = ? WHERE id = ?', [userId, id]);
|
await pool.query('UPDATE activities SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ?', [userId, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restore(id) {
|
export async function restore(id) {
|
||||||
await pool.query('UPDATE activities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
|
await pool.query('UPDATE activities SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Las tareas (tasks.activity_id) y los comentarios/adjuntos (entity_id de la actividad) se
|
// Las tareas (tasks.activity_id) y los comentarios/adjuntos (entity_id de la actividad) se
|
||||||
@@ -94,7 +94,7 @@ export async function restore(id) {
|
|||||||
// Las dependencias solo tienen sentido dentro del mismo proyecto (addDependency ya lo exige), asi
|
// Las dependencias solo tienen sentido dentro del mismo proyecto (addDependency ya lo exige), asi
|
||||||
// que las existentes (en cualquiera de los dos sentidos) quedan invalidas al cambiar de proyecto.
|
// que las existentes (en cualquiera de los dos sentidos) quedan invalidas al cambiar de proyecto.
|
||||||
export async function moveToProject(id, projectId) {
|
export async function moveToProject(id, projectId) {
|
||||||
await pool.query('UPDATE activities SET project_id = ?, sort_order = 0 WHERE id = ?', [projectId, id]);
|
await pool.query('UPDATE activities SET project_id = ?, sort_order = 0, edit_version = edit_version + 1 WHERE id = ?', [projectId, id]);
|
||||||
await pool.query('DELETE FROM activity_dependencies WHERE activity_id = ? OR depends_on_activity_id = ?', [id, id]);
|
await pool.query('DELETE FROM activity_dependencies WHERE activity_id = ? OR depends_on_activity_id = ?', [id, id]);
|
||||||
return findById(id);
|
return findById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as attachmentsRepo from '../attachments/repository.js';
|
|||||||
import * as tasksRepo from '../tasks/repository.js';
|
import * as tasksRepo from '../tasks/repository.js';
|
||||||
import { recalcProjectProgress, canReadProject, canWriteProject } from '../projects/service.js';
|
import { recalcProjectProgress, canReadProject, canWriteProject } from '../projects/service.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { ROLES } from '../../middleware/auth.js';
|
import { ROLES } from '../../middleware/auth.js';
|
||||||
import { createNotification } from '../notifications/service.js';
|
import { createNotification } from '../notifications/service.js';
|
||||||
@@ -84,8 +85,9 @@ export async function updateActivity(id, payload, currentUser) {
|
|||||||
? activity.responsible_user_id
|
? activity.responsible_user_id
|
||||||
: (payload.responsibleUserId ?? activity.responsible_user_id);
|
: (payload.responsibleUserId ?? activity.responsible_user_id);
|
||||||
|
|
||||||
|
requireEditVersion(payload.editVersion);
|
||||||
const taskCount = await repo.countTasks(id);
|
const taskCount = await repo.countTasks(id);
|
||||||
const updated = await repo.update(id, { ...payload, responsibleUserId: nextResponsible });
|
const updated = requireUpdated(await repo.update(id, { ...payload, responsibleUserId: nextResponsible }));
|
||||||
|
|
||||||
if (taskCount === 0 && payload.progressPct !== undefined) {
|
if (taskCount === 0 && payload.progressPct !== undefined) {
|
||||||
const pct = Math.max(0, Math.min(100, Number(payload.progressPct)));
|
const pct = Math.max(0, Math.min(100, Number(payload.progressPct)));
|
||||||
|
|||||||
@@ -24,21 +24,36 @@ export const getAttachments = asyncHandler(async (req, res) => {
|
|||||||
ok(res, await repo.findByEntity(entityType, Number(entityId)));
|
ok(res, await repo.findByEntity(entityType, Number(entityId)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const preflightAttachment = asyncHandler(async (req, res, next) => {
|
||||||
|
const { entityType, entityId } = req.query;
|
||||||
|
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isSafeInteger(Number(entityId)) || Number(entityId) < 1) 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');
|
||||||
|
const project = await getProjectOr404(entityType, Number(entityId));
|
||||||
|
if (!(await canWriteProject(req.currentUser, project))) throw new HttpError(404, 'No encontrado');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
export const postAttachment = asyncHandler(async (req, res) => {
|
export const postAttachment = asyncHandler(async (req, res) => {
|
||||||
const { entityType, entityId } = req.body || {};
|
const { entityType, entityId } = req.query;
|
||||||
|
let attachment;
|
||||||
|
try {
|
||||||
if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) throw new HttpError(400, 'entityType y entityId son obligatorios');
|
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.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura');
|
||||||
const project = await getProjectOr404(entityType, Number(entityId));
|
const project = await getProjectOr404(entityType, Number(entityId));
|
||||||
if (!(await canWriteProject(req.currentUser, project))) throw new HttpError(403, 'No tienes permisos de escritura en este proyecto');
|
if (!(await canWriteProject(req.currentUser, project))) throw new HttpError(403, 'No tienes permisos de escritura en este proyecto');
|
||||||
if (!req.file) throw new HttpError(400, 'Archivo obligatorio');
|
if (!req.file) throw new HttpError(400, 'Archivo obligatorio');
|
||||||
|
|
||||||
const attachment = await repo.create({
|
attachment = await repo.create({
|
||||||
entityType, entityId: Number(entityId),
|
entityType, entityId: Number(entityId),
|
||||||
filename: req.file.filename, originalName: req.file.originalname,
|
filename: req.file.filename, originalName: req.file.originalname,
|
||||||
mimeType: req.file.mimetype, size: req.file.size, uploadedBy: req.currentUser.id,
|
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 } });
|
await logAudit({ userId: req.currentUser.id, action: 'upload', entityType, entityId: Number(entityId), detail: { attachmentId: attachment.id } });
|
||||||
ok(res, attachment, 201);
|
ok(res, attachment, 201);
|
||||||
|
} catch (err) {
|
||||||
|
if (req.file && !attachment) await fs.promises.unlink(req.file.path).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const downloadAttachment = asyncHandler(async (req, res) => {
|
export const downloadAttachment = asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
import { upload } from '../../middleware/upload.js';
|
import { upload } from '../../middleware/upload.js';
|
||||||
import { getAttachments, postAttachment, downloadAttachment, deleteAttachment } from './controller.js';
|
import { getAttachments, postAttachment, preflightAttachment, downloadAttachment, deleteAttachment } from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.get('/', getAttachments);
|
router.get('/', getAttachments);
|
||||||
router.post('/', upload.single('file'), postAttachment);
|
router.post('/', preflightAttachment, upload.single('file'), postAttachment);
|
||||||
router.get('/:id/download', downloadAttachment);
|
router.get('/:id/download', downloadAttachment);
|
||||||
router.delete('/:id', deleteAttachment);
|
router.delete('/:id', deleteAttachment);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { HttpError } from '../../utils/http.js';
|
|||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { sendMail } from '../../utils/mailer.js';
|
import { sendMail } from '../../utils/mailer.js';
|
||||||
import { createNotification } from '../notifications/service.js';
|
import { createNotification } from '../notifications/service.js';
|
||||||
|
import { verificationEmail, recoveryEmail } from '../../utils/emailHtml.js';
|
||||||
|
|
||||||
const STATUS_MESSAGES = {
|
const STATUS_MESSAGES = {
|
||||||
pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.',
|
pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.',
|
||||||
@@ -20,7 +21,7 @@ function newToken() {
|
|||||||
|
|
||||||
export async function login(email, password) {
|
export async function login(email, password) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id,
|
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id, u.session_version,
|
||||||
r.code AS role_code, r.name AS role_name
|
r.code AS role_code, r.name AS role_name
|
||||||
FROM users u JOIN roles r ON r.id = u.role_id
|
FROM users u JOIN roles r ON r.id = u.role_id
|
||||||
WHERE u.email = ? LIMIT 1`,
|
WHERE u.email = ? LIMIT 1`,
|
||||||
@@ -35,7 +36,7 @@ export async function login(email, password) {
|
|||||||
if (STATUS_MESSAGES[user.status]) throw new HttpError(403, STATUS_MESSAGES[user.status]);
|
if (STATUS_MESSAGES[user.status]) throw new HttpError(403, STATUS_MESSAGES[user.status]);
|
||||||
if (!user.is_active) throw new HttpError(403, 'Cuenta inactiva. Contacta a un administrador.');
|
if (!user.is_active) throw new HttpError(403, 'Cuenta inactiva. Contacta a un administrador.');
|
||||||
|
|
||||||
const token = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
const token = jwt.sign({ sub: user.id, sv: user.session_version }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
||||||
|
|
||||||
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
|
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
|
||||||
|
|
||||||
@@ -59,40 +60,43 @@ export async function register({ name, email, password }) {
|
|||||||
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
||||||
if (existing.length) throw new HttpError(400, 'Ya existe una cuenta con ese correo');
|
if (existing.length) throw new HttpError(400, 'Ya existe una cuenta con ese correo');
|
||||||
|
|
||||||
const [[{ total }]] = await pool.query('SELECT COUNT(*) AS total FROM users');
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const connection = await pool.getConnection();
|
||||||
if (total === 0) {
|
let bootstrap;
|
||||||
// Primer usuario del sistema: superadmin, sin correo ni aprobacion (no hay quien lo apruebe).
|
let result;
|
||||||
const [superadminRole] = await pool.query("SELECT id FROM roles WHERE code = 'superadmin'");
|
let verificationToken;
|
||||||
const [result] = await pool.query(
|
try {
|
||||||
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at)
|
await connection.beginTransaction();
|
||||||
VALUES (?, ?, ?, ?, 1, 'active', NOW())`,
|
// El bloqueo de la fila de roles serializa incluso los registros con correos distintos.
|
||||||
[name, email, passwordHash, superadminRole[0].id]
|
const [roles] = await connection.query("SELECT id, code FROM roles WHERE code IN ('superadmin', 'collaborator') FOR UPDATE");
|
||||||
|
const [[{ total }]] = await connection.query('SELECT COUNT(*) AS total FROM users');
|
||||||
|
bootstrap = total === 0;
|
||||||
|
const roleId = roles.find((role) => role.code === (bootstrap ? 'superadmin' : 'collaborator'))?.id;
|
||||||
|
if (!roleId) throw new Error('Rol de registro no configurado');
|
||||||
|
verificationToken = bootstrap ? null : newToken();
|
||||||
|
[result] = await connection.query(
|
||||||
|
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at, verification_token, verification_token_expires)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ${bootstrap ? 'NOW()' : 'NULL'}, ?, ${bootstrap ? 'NULL' : 'DATE_ADD(NOW(), INTERVAL 24 HOUR)'})`,
|
||||||
|
[name, email, passwordHash, roleId, bootstrap ? 1 : 0, bootstrap ? 'active' : 'pending_verification', verificationToken]
|
||||||
);
|
);
|
||||||
|
await connection.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await connection.rollback();
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') throw new HttpError(400, 'Ya existe una cuenta con ese correo');
|
||||||
|
throw err;
|
||||||
|
} finally { connection.release(); }
|
||||||
|
if (bootstrap) {
|
||||||
await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId });
|
await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId });
|
||||||
const token = jwt.sign({ sub: result.insertId }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
const token = jwt.sign({ sub: result.insertId, sv: 1 }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
||||||
return {
|
return { bootstrap: true, token, user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null } };
|
||||||
bootstrap: true,
|
|
||||||
token,
|
|
||||||
user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [collaboratorRole] = await pool.query("SELECT id FROM roles WHERE code = 'collaborator'");
|
|
||||||
const verificationToken = newToken();
|
|
||||||
const [result] = await pool.query(
|
|
||||||
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, verification_token, verification_token_expires)
|
|
||||||
VALUES (?, ?, ?, ?, 0, 'pending_verification', ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))`,
|
|
||||||
[name, email, passwordHash, collaboratorRole[0].id, verificationToken]
|
|
||||||
);
|
|
||||||
await logAudit({ userId: result.insertId, action: 'register', entityType: 'user', entityId: result.insertId });
|
await logAudit({ userId: result.insertId, action: 'register', entityType: 'user', entityId: result.insertId });
|
||||||
|
|
||||||
const verifyUrl = `${env.corsOrigin}/verificar-correo?token=${verificationToken}`;
|
const verifyUrl = `${env.corsOrigin}/verificar-correo?token=${verificationToken}`;
|
||||||
await sendMail({
|
await sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Confirma tu correo — project.linkedcorp.net',
|
subject: 'Confirma tu correo — project.linkedcorp.net',
|
||||||
html: `<p>Hola ${name},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>Este enlace vence en 24 horas.</p>`,
|
html: verificationEmail(name, verifyUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { bootstrap: false };
|
return { bootstrap: false };
|
||||||
@@ -160,7 +164,7 @@ export async function acceptInvite(token, name, password) {
|
|||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', email_verified_at = NOW(),
|
`UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', edit_version = edit_version + 1, email_verified_at = NOW(),
|
||||||
verification_token = NULL, verification_token_expires = NULL
|
verification_token = NULL, verification_token_expires = NULL
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[name, passwordHash, user.id]
|
[name, passwordHash, user.id]
|
||||||
@@ -168,11 +172,11 @@ export async function acceptInvite(token, name, password) {
|
|||||||
await logAudit({ userId: user.id, action: 'accept_invite', entityType: 'user', entityId: user.id });
|
await logAudit({ userId: user.id, action: 'accept_invite', entityType: 'user', entityId: user.id });
|
||||||
|
|
||||||
const [full] = await pool.query(
|
const [full] = await pool.query(
|
||||||
`SELECT u.id, u.name, u.email, u.department_id, r.code AS role_code, r.name AS role_name
|
`SELECT u.id, u.name, u.email, u.department_id, u.session_version, r.code AS role_code, r.name AS role_name
|
||||||
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
|
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
|
||||||
[user.id]
|
[user.id]
|
||||||
);
|
);
|
||||||
const jwtToken = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
const jwtToken = jwt.sign({ sub: user.id, sv: full[0].session_version }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
||||||
return {
|
return {
|
||||||
token: jwtToken,
|
token: jwtToken,
|
||||||
user: {
|
user: {
|
||||||
@@ -200,7 +204,7 @@ export async function forgotPassword(email) {
|
|||||||
await sendMail({
|
await sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Recuperar contraseña — project.linkedcorp.net',
|
subject: 'Recuperar contraseña — project.linkedcorp.net',
|
||||||
html: `<p>Hola ${user.name},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`,
|
html: recoveryEmail(user.name, resetUrl),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +221,7 @@ export async function resetPassword(token, newPassword) {
|
|||||||
|
|
||||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`,
|
`UPDATE users SET password_hash = ?, session_version = session_version + 1, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`,
|
||||||
[passwordHash, user.id]
|
[passwordHash, user.id]
|
||||||
);
|
);
|
||||||
await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id });
|
await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id });
|
||||||
@@ -236,6 +240,6 @@ export async function changePassword(userId, currentPassword, newPassword) {
|
|||||||
if (!validPassword) throw new HttpError(400, 'La clave actual no es correcta');
|
if (!validPassword) throw new HttpError(400, 'La clave actual no es correcta');
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, userId]);
|
await pool.query('UPDATE users SET password_hash = ?, session_version = session_version + 1 WHERE id = ?', [passwordHash, userId]);
|
||||||
await logAudit({ userId, action: 'change_password', entityType: 'user', entityId: userId });
|
await logAudit({ userId, action: 'change_password', entityType: 'user', entityId: userId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
import { ok, fail, asyncHandler } from '../../utils/http.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { CATALOGS } from './repository.js';
|
import { CATALOGS } from './repository.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
|
||||||
function validKey(req, res) {
|
function validKey(req, res) {
|
||||||
@@ -29,7 +30,8 @@ export const postCatalog = asyncHandler(async (req, res) => {
|
|||||||
export const putCatalog = asyncHandler(async (req, res) => {
|
export const putCatalog = asyncHandler(async (req, res) => {
|
||||||
const key = validKey(req, res);
|
const key = validKey(req, res);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const item = await repo.update(key, Number(req.params.id), req.body);
|
requireEditVersion(req.body.editVersion);
|
||||||
|
const item = requireUpdated(await repo.update(key, Number(req.params.id), req.body));
|
||||||
await logAudit({ userId: req.currentUser.id, action: 'update', entityType: `catalog:${key}`, entityId: item.id });
|
await logAudit({ userId: req.currentUser.id, action: 'update', entityType: `catalog:${key}`, entityId: item.id });
|
||||||
ok(res, item);
|
ok(res, item);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export async function update(catalogKey, id, payload) {
|
|||||||
const cfg = CATALOGS[catalogKey];
|
const cfg = CATALOGS[catalogKey];
|
||||||
const cols = cfg.fields.filter((f) => payload[f] !== undefined);
|
const cols = cfg.fields.filter((f) => payload[f] !== undefined);
|
||||||
const setClause = cols.map((c) => `${c} = ?`).join(', ');
|
const setClause = cols.map((c) => `${c} = ?`).join(', ');
|
||||||
await pool.query(`UPDATE ${cfg.table} SET ${setClause} WHERE id = ?`, [...cols.map((c) => payload[c]), id]);
|
const [result] = await pool.query(`UPDATE ${cfg.table} SET ${setClause}, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?`, [...cols.map((c) => payload[c]), id, payload.editVersion]);
|
||||||
return { id, ...payload };
|
return result.affectedRows ? { id, ...payload, edit_version: payload.editVersion + 1 } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(catalogKey, id) {
|
export async function remove(catalogKey, id) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const postComment = asyncHandler(async (req, res) => {
|
|||||||
// Solo el autor, o superadmin/admin, puede editar/borrar -- nunca otro colaborador aunque tenga
|
// Solo el autor, o superadmin/admin, puede editar/borrar -- nunca otro colaborador aunque tenga
|
||||||
// permiso de escritura en el proyecto (un comentario es responsabilidad de quien lo escribio).
|
// permiso de escritura en el proyecto (un comentario es responsabilidad de quien lo escribio).
|
||||||
function canManageComment(currentUser, comment) {
|
function canManageComment(currentUser, comment) {
|
||||||
return comment.user_id === currentUser.id || [ROLES.SUPERADMIN, ROLES.ADMIN].includes(currentUser.roleCode);
|
return currentUser.roleCode !== ROLES.VIEWER && (comment.user_id === currentUser.id || [ROLES.SUPERADMIN, ROLES.ADMIN].includes(currentUser.roleCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const putComment = asyncHandler(async (req, res) => {
|
export const putComment = asyncHandler(async (req, res) => {
|
||||||
@@ -46,7 +46,10 @@ export const putComment = asyncHandler(async (req, res) => {
|
|||||||
if (!canManageComment(req.currentUser, comment)) throw new HttpError(403, 'Solo quien escribio el comentario puede editarlo');
|
if (!canManageComment(req.currentUser, comment)) throw new HttpError(403, 'Solo quien escribio el comentario puede editarlo');
|
||||||
const message = (req.body?.message || '').trim();
|
const message = (req.body?.message || '').trim();
|
||||||
if (!message) throw new HttpError(400, 'El mensaje es obligatorio');
|
if (!message) throw new HttpError(400, 'El mensaje es obligatorio');
|
||||||
const updated = await repo.update(comment.id, message);
|
const editVersion = req.body?.editVersion;
|
||||||
|
if (!Number.isSafeInteger(editVersion) || editVersion < 1) throw new HttpError(428, 'Falta la version del comentario');
|
||||||
|
const updated = await repo.update(comment.id, message, editVersion);
|
||||||
|
if (!updated) throw new HttpError(409, 'El comentario cambio en el servidor. Recarga antes de guardar');
|
||||||
await logAudit({ userId: req.currentUser.id, action: 'comment_edit', entityType: comment.entity_type, entityId: comment.entity_id, detail: { commentId: comment.id } });
|
await logAudit({ userId: req.currentUser.id, action: 'comment_edit', entityType: comment.entity_type, entityId: comment.entity_id, detail: { commentId: comment.id } });
|
||||||
ok(res, updated);
|
ok(res, updated);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { pool } from '../../config/db.js';
|
|||||||
|
|
||||||
export async function findByEntity(entityType, entityId) {
|
export async function findByEntity(entityType, entityId) {
|
||||||
const [rows] = await pool.query(
|
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, c.updated_at
|
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.edit_version, c.created_at, c.updated_at
|
||||||
FROM comments c JOIN users u ON u.id = c.user_id
|
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`,
|
WHERE c.entity_type = ? AND c.entity_id = ? ORDER BY c.created_at`,
|
||||||
[entityType, entityId]
|
[entityType, entityId]
|
||||||
@@ -12,16 +12,16 @@ export async function findByEntity(entityType, entityId) {
|
|||||||
|
|
||||||
export async function findById(id) {
|
export async function findById(id) {
|
||||||
const [rows] = await pool.query(
|
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, c.updated_at
|
`SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.edit_version, c.created_at, c.updated_at
|
||||||
FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`,
|
FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id, message) {
|
export async function update(id, message, editVersion) {
|
||||||
await pool.query('UPDATE comments SET message = ? WHERE id = ?', [message, id]);
|
const [result] = await pool.query('UPDATE comments SET message = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [message, id, editVersion]);
|
||||||
return findById(id);
|
return result.affectedRows ? findById(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(id) {
|
export async function remove(id) {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export const getCompany = asyncHandler(async (req, res) => ok(res, await service
|
|||||||
export const getCompanyLogo = asyncHandler(async (req, res) => {
|
export const getCompanyLogo = asyncHandler(async (req, res) => {
|
||||||
const file = await service.getCompanyLogoFile();
|
const file = await service.getCompanyLogoFile();
|
||||||
if (!file) throw new HttpError(404, 'No hay logo configurado');
|
if (!file) throw new HttpError(404, 'No hay logo configurado');
|
||||||
|
res.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.set('Content-Security-Policy', "default-src 'none'; sandbox");
|
||||||
// sendFile exige ruta absoluta (a diferencia de res.download, que la resuelve sola).
|
// sendFile exige ruta absoluta (a diferencia de res.download, que la resuelve sola).
|
||||||
res.type(file.mime).sendFile(path.resolve(file.filePath));
|
res.type(file.mime).sendFile(path.resolve(file.filePath));
|
||||||
});
|
});
|
||||||
|
|
||||||
export const putCompany = asyncHandler(async (req, res) => {
|
export const putCompany = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.updateCompanyInfo({ name: req.body.name, file: req.file }, req.currentUser.id));
|
ok(res, await service.updateCompanyInfo({ name: req.body.name, file: req.file, editVersion: Number(req.body.editVersion) }, req.currentUser.id));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import path from 'node:path';
|
|||||||
import * as settingsRepo from '../settings/repository.js';
|
import * as settingsRepo from '../settings/repository.js';
|
||||||
import { logoDir } from '../../middleware/uploadLogo.js';
|
import { logoDir } from '../../middleware/uploadLogo.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
|
||||||
const COMPANY_KEYS = ['company_name', 'company_logo_filename', 'company_logo_mime'];
|
const COMPANY_KEYS = ['company_name', 'company_logo_filename', 'company_logo_mime', 'company_edit_version'];
|
||||||
const DEFAULT_NAME = 'LinkedCorp';
|
const DEFAULT_NAME = 'LinkedCorp';
|
||||||
|
|
||||||
export async function getCompanyInfo() {
|
export async function getCompanyInfo() {
|
||||||
const values = await settingsRepo.getAll(COMPANY_KEYS);
|
const values = await settingsRepo.getAll(COMPANY_KEYS);
|
||||||
return {
|
return {
|
||||||
name: values.company_name || DEFAULT_NAME,
|
name: values.company_name || DEFAULT_NAME,
|
||||||
hasLogo: !!values.company_logo_filename,
|
edit_version: Number(values.company_edit_version),
|
||||||
|
hasLogo: !!values.company_logo_filename && ['image/png', 'image/jpeg', 'image/webp'].includes(values.company_logo_mime),
|
||||||
// El propio nombre de archivo (unico por subida) sirve de version para cache-busting del logo.
|
// El propio nombre de archivo (unico por subida) sirve de version para cache-busting del logo.
|
||||||
logoVersion: values.company_logo_filename || null,
|
logoVersion: values.company_logo_filename || null,
|
||||||
};
|
};
|
||||||
@@ -21,28 +23,45 @@ export async function getCompanyInfo() {
|
|||||||
export async function getCompanyLogoFile() {
|
export async function getCompanyLogoFile() {
|
||||||
const values = await settingsRepo.getAll(['company_logo_filename', 'company_logo_mime']);
|
const values = await settingsRepo.getAll(['company_logo_filename', 'company_logo_mime']);
|
||||||
if (!values.company_logo_filename) return null;
|
if (!values.company_logo_filename) return null;
|
||||||
|
if (!['image/png', 'image/jpeg', 'image/webp'].includes(values.company_logo_mime)) return null;
|
||||||
const filePath = path.join(logoDir, values.company_logo_filename);
|
const filePath = path.join(logoDir, values.company_logo_filename);
|
||||||
if (!fs.existsSync(filePath)) return null;
|
if (!fs.existsSync(filePath)) return null;
|
||||||
return { filePath, mime: values.company_logo_mime || 'application/octet-stream' };
|
return { filePath, mime: values.company_logo_mime || 'application/octet-stream' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCompanyInfo({ name, file }, actorId) {
|
export async function updateCompanyInfo({ name, file, editVersion }, actorId) {
|
||||||
|
requireEditVersion(editVersion);
|
||||||
const entries = {};
|
const entries = {};
|
||||||
|
let oldLogoFilename;
|
||||||
if (name !== undefined) {
|
if (name !== undefined) {
|
||||||
const trimmed = (name || '').trim();
|
const trimmed = (name || '').trim();
|
||||||
if (!trimmed) throw new HttpError(400, 'El nombre de la empresa es obligatorio');
|
if (!trimmed) throw new HttpError(400, 'El nombre de la empresa es obligatorio');
|
||||||
entries.company_name = trimmed;
|
entries.company_name = trimmed;
|
||||||
}
|
}
|
||||||
if (file) {
|
if (file) {
|
||||||
|
const bytes = await fs.promises.readFile(file.path);
|
||||||
|
const png = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||||
|
const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||||
|
const webp = bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
|
||||||
|
const actualMime = png ? 'image/png' : jpeg ? 'image/jpeg' : webp ? 'image/webp' : null;
|
||||||
|
if (!actualMime || file.mimetype !== actualMime) {
|
||||||
|
await fs.promises.unlink(file.path).catch(() => {});
|
||||||
|
throw new HttpError(400, 'El contenido del logo no coincide con PNG, JPG o WEBP');
|
||||||
|
}
|
||||||
const values = await settingsRepo.getAll(['company_logo_filename']);
|
const values = await settingsRepo.getAll(['company_logo_filename']);
|
||||||
if (values.company_logo_filename) {
|
oldLogoFilename = values.company_logo_filename;
|
||||||
const oldPath = path.join(logoDir, values.company_logo_filename);
|
|
||||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
|
||||||
}
|
|
||||||
entries.company_logo_filename = file.filename;
|
entries.company_logo_filename = file.filename;
|
||||||
entries.company_logo_mime = file.mimetype;
|
entries.company_logo_mime = actualMime;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
requireUpdated(await settingsRepo.setManyIfVersion(entries, 'company_edit_version', editVersion));
|
||||||
|
} catch (err) {
|
||||||
|
if (file) await fs.promises.unlink(file.path).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (file && oldLogoFilename && oldLogoFilename !== file.filename) {
|
||||||
|
await fs.promises.unlink(path.join(logoDir, oldLogoFilename)).catch(() => {});
|
||||||
}
|
}
|
||||||
if (Object.keys(entries).length) await settingsRepo.setMany(entries);
|
|
||||||
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:company', entityId: null });
|
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:company', entityId: null });
|
||||||
return getCompanyInfo();
|
return getCompanyInfo();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pool } from '../../config/db.js';
|
import { pool } from '../../config/db.js';
|
||||||
|
|
||||||
export async function findAllFlat() {
|
export async function findAllFlat() {
|
||||||
const [rows] = await pool.query('SELECT id, name, parent_id FROM departments ORDER BY name');
|
const [rows] = await pool.query('SELECT id, name, parent_id, edit_version FROM departments ORDER BY name');
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,9 +13,9 @@ export async function create({ name, parentId }) {
|
|||||||
return { id: result.insertId, name, parent_id: parentId || null };
|
return { id: result.insertId, name, parent_id: parentId || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id, { name, parentId }) {
|
export async function update(id, { name, parentId, editVersion }) {
|
||||||
await pool.query('UPDATE departments SET name = ?, parent_id = ? WHERE id = ?', [name, parentId || null, id]);
|
const [result] = await pool.query('UPDATE departments SET name = ?, parent_id = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [name, parentId || null, id, editVersion]);
|
||||||
return { id, name, parent_id: parentId || null };
|
return result.affectedRows ? { id, name, parent_id: parentId || null, edit_version: editVersion + 1 } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(id) {
|
export async function remove(id) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
|
||||||
function toTree(flat) {
|
function toTree(flat) {
|
||||||
@@ -30,7 +31,8 @@ export async function createDepartment(payload, actorId) {
|
|||||||
export async function updateDepartment(id, payload, actorId) {
|
export async function updateDepartment(id, payload, actorId) {
|
||||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
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');
|
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);
|
requireEditVersion(payload.editVersion);
|
||||||
|
const dept = requireUpdated(await repo.update(id, payload));
|
||||||
await logAudit({ userId: actorId, action: 'update', entityType: 'department', entityId: id });
|
await logAudit({ userId: actorId, action: 'update', entityType: 'department', entityId: id });
|
||||||
return dept;
|
return dept;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import zlib from 'node:zlib';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { Worker } from 'node:worker_threads';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { HttpError, asyncHandler } from '../../utils/http.js';
|
import { HttpError, asyncHandler } from '../../utils/http.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { parseInventoryText, normalizeInventory } from './normalizer.js';
|
import { redactUrl } from '../../utils/redactUrl.js';
|
||||||
|
|
||||||
// Ingesta del agente GLPI. NO usa JWT de usuario: cada instalacion lleva un token (guardado como
|
// Ingesta del agente GLPI. NO usa JWT de usuario: cada instalacion lleva un token (guardado como
|
||||||
// hash en inventory_tokens) que el agente manda como password Basic, Bearer, X-Ingest-Token o
|
// hash en inventory_tokens) que el agente manda como password Basic, Bearer, X-Ingest-Token o
|
||||||
@@ -17,6 +20,8 @@ const MAX_INFLATED = MAX_BYTES * 4; // tope de inflado (anti zip-bomb)
|
|||||||
const REPORT_EVERY_HOURS = 24;
|
const REPORT_EVERY_HOURS = 24;
|
||||||
|
|
||||||
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
|
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
|
||||||
|
const gunzip = promisify(zlib.gunzip);
|
||||||
|
const uncompress = promisify(zlib.inflate);
|
||||||
|
|
||||||
function extractToken(req) {
|
function extractToken(req) {
|
||||||
if (req.headers['x-ingest-token']) return String(req.headers['x-ingest-token']).trim();
|
if (req.headers['x-ingest-token']) return String(req.headers['x-ingest-token']).trim();
|
||||||
@@ -32,10 +37,10 @@ function extractToken(req) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inflate(buf) {
|
async function inflate(buf) {
|
||||||
const opts = { maxOutputLength: MAX_INFLATED };
|
const opts = { maxOutputLength: MAX_INFLATED };
|
||||||
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return zlib.gunzipSync(buf, opts);
|
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return gunzip(buf, opts);
|
||||||
if (buf.length > 2 && buf[0] === 0x78) return zlib.inflateSync(buf, opts);
|
if (buf.length > 2 && buf[0] === 0x78) return uncompress(buf, opts);
|
||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,24 +51,43 @@ function detectFormat(text) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveRaw(deviceid, text, format) {
|
async function saveRaw(deviceid, text, format) {
|
||||||
const dir = path.join(env.storageRoot, 'inventories');
|
const dir = path.join(env.storageRoot, 'inventories');
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
// Un archivo por equipo (se sobrescribe): solo interesa el ultimo reporte crudo para depurar.
|
// Un archivo por equipo (se sobrescribe): solo interesa el ultimo reporte crudo para depurar.
|
||||||
const name = `${sha256(deviceid).slice(0, 24)}.${format}`;
|
const name = `${sha256(deviceid).slice(0, 24)}.${format}`;
|
||||||
fs.writeFileSync(path.join(dir, name), text);
|
await fs.promises.writeFile(path.join(dir, name), text);
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseInWorker(text, format) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
|
||||||
|
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
||||||
|
});
|
||||||
|
let settled = false;
|
||||||
|
const timeout = setTimeout(() => worker.terminate(), 15_000);
|
||||||
|
function finish(error, value) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (error) reject(error); else resolve(value);
|
||||||
|
}
|
||||||
|
worker.once('message', (value) => value.error ? finish(new Error(value.error)) : finish(null, value));
|
||||||
|
worker.once('error', (error) => finish(error));
|
||||||
|
worker.once('exit', (code) => { if (!settled) finish(new Error(`Parser interrumpido (${code})`)); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ok = (res, format) => (format === 'xml'
|
const ok = (res, format) => (format === 'xml'
|
||||||
? res.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><REPLY></REPLY>')
|
? res.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><REPLY></REPLY>')
|
||||||
: res.json({ status: 'ok' }));
|
: res.json({ status: 'ok' }));
|
||||||
|
|
||||||
const receive = asyncHandler(async (req, res) => {
|
const receive = asyncHandler(async (req, res) => {
|
||||||
const token = extractToken(req);
|
const tokenRow = req.inventoryToken;
|
||||||
const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null;
|
const token = req.inventoryTokenPresent;
|
||||||
// Log de diagnostico de TODA peticion del agente (sin imprimir el token): util para depurar la conexion.
|
// Log de diagnostico de TODA peticion del agente (sin imprimir el token): util para depurar la conexion.
|
||||||
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`);
|
console.log(`[inventario] ${req.method} ${redactUrl(req.originalUrl)} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`);
|
||||||
if (!tokenRow) {
|
if (!tokenRow) {
|
||||||
// El agente (LWP) manda las credenciales solo despues de recibir este desafio Basic.
|
// El agente (LWP) manda las credenciales solo despues de recibir este desafio Basic.
|
||||||
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
|
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
|
||||||
@@ -76,7 +100,7 @@ const receive = asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
let text;
|
let text;
|
||||||
try {
|
try {
|
||||||
text = inflate(body).toString('utf8');
|
text = (await inflate(body)).toString('utf8');
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpError(400, 'Cuerpo comprimido invalido o demasiado grande');
|
throw new HttpError(400, 'Cuerpo comprimido invalido o demasiado grande');
|
||||||
}
|
}
|
||||||
@@ -85,7 +109,7 @@ const receive = asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = parseInventoryText(text, format);
|
parsed = await parseInWorker(text, format);
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpError(400, 'No se pudo interpretar el inventario');
|
throw new HttpError(400, 'No se pudo interpretar el inventario');
|
||||||
}
|
}
|
||||||
@@ -102,8 +126,8 @@ const receive = asyncHandler(async (req, res) => {
|
|||||||
if (parsed.action && parsed.action !== 'inventory') return ok(res, format);
|
if (parsed.action && parsed.action !== 'inventory') return ok(res, format);
|
||||||
|
|
||||||
if (!parsed.deviceid) throw new HttpError(400, 'Inventario sin deviceid');
|
if (!parsed.deviceid) throw new HttpError(400, 'Inventario sin deviceid');
|
||||||
const normalized = normalizeInventory(parsed);
|
const normalized = parsed.normalized;
|
||||||
const rawFile = saveRaw(parsed.deviceid, text, format);
|
const rawFile = await saveRaw(parsed.deviceid, text, format);
|
||||||
const result = await repo.upsertInventory(parsed.deviceid, normalized, rawFile);
|
const result = await repo.upsertInventory(parsed.deviceid, normalized, rawFile);
|
||||||
console.log(`[inventario] ${result.created ? 'nuevo' : 'actualizado'} #${result.id} ${normalized.device.hostname || parsed.deviceid} (${format}, ${result.changeCount} cambios)`);
|
console.log(`[inventario] ${result.created ? 'nuevo' : 'actualizado'} #${result.id} ${normalized.device.hostname || parsed.deviceid} (${format}, ${result.changeCount} cambios)`);
|
||||||
return ok(res, format);
|
return ok(res, format);
|
||||||
@@ -111,13 +135,32 @@ const receive = asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const rawBody = express.raw({ type: () => true, limit: MAX_BYTES });
|
const rawBody = express.raw({ type: () => true, limit: MAX_BYTES });
|
||||||
|
const limiter = rateLimit({ windowMs: 60_000, limit: 120, standardHeaders: 'draft-7', legacyHeaders: false });
|
||||||
|
let activeRequests = 0;
|
||||||
|
const preflight = asyncHandler(async (req, res, next) => {
|
||||||
|
const token = extractToken(req);
|
||||||
|
const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null;
|
||||||
|
req.inventoryTokenPresent = !!token;
|
||||||
|
req.inventoryToken = tokenRow;
|
||||||
|
if (!tokenRow) {
|
||||||
|
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
|
||||||
|
throw new HttpError(401, 'No autorizado');
|
||||||
|
}
|
||||||
|
if (activeRequests >= 2) throw new HttpError(503, 'Ingesta ocupada; reintenta');
|
||||||
|
activeRequests++;
|
||||||
|
let released = false;
|
||||||
|
function release() { if (!released) { released = true; activeRequests--; } }
|
||||||
|
res.once('finish', release);
|
||||||
|
res.once('close', release);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Ruta propia + alias de la ruta nativa de GLPI (para no parchear el agente).
|
// Ruta propia + alias de la ruta nativa de GLPI (para no parchear el agente).
|
||||||
router.post('/api/inventory', rawBody, receive);
|
router.post('/api/inventory', limiter, preflight, rawBody, receive);
|
||||||
router.post('/front/inventory.php', rawBody, receive);
|
router.post('/front/inventory.php', limiter, preflight, rawBody, receive);
|
||||||
// Cualquier otro metodo/ruta que el agente pruebe bajo estas rutas queda registrado.
|
// Cualquier otro metodo/ruta que el agente pruebe bajo estas rutas queda registrado.
|
||||||
router.all(['/api/inventory', '/front/inventory.php'], (req, res) => {
|
router.all(['/api/inventory', '/front/inventory.php'], (req, res) => {
|
||||||
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} (metodo no soportado)`);
|
console.log(`[inventario] ${req.method} ${redactUrl(req.originalUrl)} desde ${req.ip} (metodo no soportado)`);
|
||||||
res.status(405).json({ success: false, data: null, error: 'Usa POST' });
|
res.status(405).json({ success: false, data: null, error: 'Usa POST' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { parentPort, workerData } from 'node:worker_threads';
|
||||||
|
import { parseInventoryText, normalizeInventory } from './normalizer.js';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseInventoryText(workerData.text, workerData.format);
|
||||||
|
parentPort.postMessage({ action: parsed.action, deviceid: parsed.deviceid,
|
||||||
|
normalized: parsed.action === 'inventory' || !parsed.action ? normalizeInventory(parsed) : null });
|
||||||
|
} catch {
|
||||||
|
parentPort.postMessage({ error: 'No se pudo interpretar el inventario' });
|
||||||
|
}
|
||||||
@@ -217,11 +217,12 @@ export async function findById(id) {
|
|||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateManual(id, { status, assignedUserId, departmentId, notes }) {
|
export async function updateManual(id, { status, assignedUserId, departmentId, notes, editVersion }) {
|
||||||
await pool.query(
|
const [result] = await pool.query(
|
||||||
'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ? WHERE id = ?',
|
'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?',
|
||||||
[status, assignedUserId, departmentId, notes, id]
|
[status, assignedUserId, departmentId, notes, id, editVersion]
|
||||||
);
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findRawFile(id) {
|
export async function findRawFile(id) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
|
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
||||||
import { fail } from '../../utils/http.js';
|
import { fail } from '../../utils/http.js';
|
||||||
import { getAccess as getAccessFor } from './service.js';
|
import { getAccess as getAccessFor } from './service.js';
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +11,6 @@ const router = Router();
|
|||||||
|
|
||||||
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
|
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
|
||||||
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
|
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
|
||||||
const CAN_EDIT = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
|
|
||||||
const CAN_MANAGE = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
|
||||||
|
|
||||||
async function canView(req, res, next) {
|
async function canView(req, res, next) {
|
||||||
try {
|
try {
|
||||||
@@ -27,17 +25,17 @@ router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para
|
|||||||
router.get('/dashboard', canView, getDashboard);
|
router.get('/dashboard', canView, getDashboard);
|
||||||
router.get('/software', canView, getSoftware);
|
router.get('/software', canView, getSoftware);
|
||||||
router.get('/software/devices', canView, getSoftwareDevices);
|
router.get('/software/devices', canView, getSoftwareDevices);
|
||||||
router.get('/viewers', requireRole(...CAN_MANAGE), getViewers);
|
router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers);
|
||||||
router.post('/viewers', requireRole(...CAN_MANAGE), postViewer);
|
router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer);
|
||||||
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE), deleteViewer);
|
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer);
|
||||||
router.get('/tokens', requireRole(...CAN_MANAGE), getTokens);
|
router.get('/tokens', requireRole(...CAN_MANAGE_DEVICES), getTokens);
|
||||||
router.post('/tokens', requireRole(...CAN_MANAGE), postToken);
|
router.post('/tokens', requireRole(...CAN_MANAGE_DEVICES), postToken);
|
||||||
router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE), getTokenSecret);
|
router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE_DEVICES), getTokenSecret);
|
||||||
router.patch('/tokens/:id', requireRole(...CAN_MANAGE), patchToken);
|
router.patch('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), patchToken);
|
||||||
router.delete('/tokens/:id', requireRole(...CAN_MANAGE), deleteToken);
|
router.delete('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), deleteToken);
|
||||||
router.get('/', canView, getDevices);
|
router.get('/', canView, getDevices);
|
||||||
router.get('/:id', canView, getDevice);
|
router.get('/:id', canView, getDevice);
|
||||||
router.get('/:id/raw', requireRole(...CAN_EDIT), getRaw);
|
router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw);
|
||||||
router.put('/:id', requireRole(...CAN_EDIT), putDevice);
|
router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { encrypt, decrypt } from '../../utils/crypto.js';
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
||||||
|
|
||||||
const STATUSES = ['active', 'retired'];
|
const STATUSES = ['active', 'retired'];
|
||||||
const STALE_DAYS = 7;
|
const STALE_DAYS = 7;
|
||||||
@@ -34,7 +36,8 @@ export async function updateDevice(id, data, actor) {
|
|||||||
departmentId: data.departmentId === undefined ? current.department_id : data.departmentId || null,
|
departmentId: data.departmentId === undefined ? current.department_id : data.departmentId || null,
|
||||||
notes: data.notes === undefined ? current.notes : (String(data.notes).trim() || null),
|
notes: data.notes === undefined ? current.notes : (String(data.notes).trim() || null),
|
||||||
};
|
};
|
||||||
await repo.updateManual(id, next);
|
requireEditVersion(data.editVersion);
|
||||||
|
requireUpdated(await repo.updateManual(id, { ...next, editVersion: data.editVersion }));
|
||||||
await logAudit({ userId: actor.id, action: 'update', entityType: 'device', entityId: id, detail: { hostname: current.hostname, ...next } });
|
await logAudit({ userId: actor.id, action: 'update', entityType: 'device', entityId: id, detail: { hostname: current.hostname, ...next } });
|
||||||
return getDevice(id);
|
return getDevice(id);
|
||||||
}
|
}
|
||||||
@@ -51,14 +54,12 @@ export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() ||
|
|||||||
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
||||||
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
||||||
|
|
||||||
const FULL_ACCESS_ROLES = ['superadmin', 'admin', 'it_manager'];
|
|
||||||
const MANAGE_ROLES = ['superadmin', 'admin'];
|
|
||||||
|
|
||||||
// Que puede hacer este usuario en Equipos: ver (rol de TI o compartido), editar, gestionar (agentes/compartir).
|
// Que puede hacer este usuario en Equipos: ver (rol de TI o compartido), editar, gestionar (agentes/compartir).
|
||||||
export async function getAccess(user) {
|
export async function getAccess(user) {
|
||||||
const full = FULL_ACCESS_ROLES.includes(user.roleCode);
|
const full = CAN_EDIT_DEVICES.includes(user.roleCode);
|
||||||
const canView = full || (await repo.isViewer(user.id));
|
const canView = full || (await repo.isViewer(user.id));
|
||||||
return { canView, canEdit: full, canManage: MANAGE_ROLES.includes(user.roleCode) };
|
return { canView, canEdit: full, canManage: CAN_MANAGE_DEVICES.includes(user.roleCode) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listViewers = () => repo.listViewers();
|
export const listViewers = () => repo.listViewers();
|
||||||
@@ -66,7 +67,7 @@ export const listViewers = () => repo.listViewers();
|
|||||||
export async function addViewer(userId, actor) {
|
export async function addViewer(userId, actor) {
|
||||||
const target = await repo.findUserBrief(userId);
|
const target = await repo.findUserBrief(userId);
|
||||||
if (!target || !target.is_active) throw new HttpError(404, 'Usuario no encontrado');
|
if (!target || !target.is_active) throw new HttpError(404, 'Usuario no encontrado');
|
||||||
if (FULL_ACCESS_ROLES.includes(target.role_code)) throw new HttpError(400, 'Ese usuario ya tiene acceso completo por su rol');
|
if (CAN_EDIT_DEVICES.includes(target.role_code)) throw new HttpError(400, 'Ese usuario ya tiene acceso completo por su rol');
|
||||||
await repo.addViewer(userId, actor.id);
|
await repo.addViewer(userId, actor.id);
|
||||||
await logAudit({ userId: actor.id, action: 'share', entityType: 'devices', entityId: userId, detail: { name: target.name } });
|
await logAudit({ userId: actor.id, action: 'share', entityType: 'devices', entityId: userId, detail: { name: target.name } });
|
||||||
return repo.listViewers();
|
return repo.listViewers();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken';
|
|||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { redactUrl } from '../../utils/redactUrl.js';
|
||||||
|
|
||||||
// Severidad SIEMPRE automatica, nunca etiquetada a mano (mismo criterio que la criticidad del
|
// Severidad SIEMPRE automatica, nunca etiquetada a mano (mismo criterio que la criticidad del
|
||||||
// panel de Actualizaciones): backend y errores de render de React "alta" (rompen toda la
|
// panel de Actualizaciones): backend y errores de render de React "alta" (rompen toda la
|
||||||
@@ -22,7 +23,7 @@ export async function logBackendError(err, req) {
|
|||||||
severity: 'alta',
|
severity: 'alta',
|
||||||
message: (err?.message || String(err)).slice(0, 2000),
|
message: (err?.message || String(err)).slice(0, 2000),
|
||||||
stack: (err?.stack || '').slice(0, 5000) || null,
|
stack: (err?.stack || '').slice(0, 5000) || null,
|
||||||
url: req?.originalUrl?.slice(0, 500) || null,
|
url: redactUrl(req?.originalUrl).slice(0, 500) || null,
|
||||||
method: req?.method || null,
|
method: req?.method || null,
|
||||||
userId: req?.currentUser?.id || null,
|
userId: req?.currentUser?.id || null,
|
||||||
userAgent: (req?.headers?.['user-agent'] || '').slice(0, 255) || null,
|
userAgent: (req?.headers?.['user-agent'] || '').slice(0, 255) || null,
|
||||||
@@ -53,7 +54,7 @@ export async function logFrontendError(payload, req) {
|
|||||||
severity: normalizeSeverity(payload.severity),
|
severity: normalizeSeverity(payload.severity),
|
||||||
message,
|
message,
|
||||||
stack: (payload.stack || '').slice(0, 5000) || null,
|
stack: (payload.stack || '').slice(0, 5000) || null,
|
||||||
url: (payload.url || '').slice(0, 500) || null,
|
url: redactUrl(payload.url || '').slice(0, 500) || null,
|
||||||
method: null,
|
method: null,
|
||||||
userId: decodeOptionalUserId(req),
|
userId: decodeOptionalUserId(req),
|
||||||
userAgent: (req.headers['user-agent'] || '').slice(0, 255) || null,
|
userAgent: (req.headers['user-agent'] || '').slice(0, 255) || null,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const postNote = asyncHandler(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const putNote = asyncHandler(async (req, res) => {
|
export const putNote = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.updateNote(Number(req.params.id), req.body.content, req.currentUser));
|
ok(res, await service.updateNote(Number(req.params.id), req.body.content, req.body.editVersion, req.currentUser));
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteNote = asyncHandler(async (req, res) => {
|
export const deleteNote = asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { pool } from '../../config/db.js';
|
|||||||
// su orden manual), compartidas despues (por fecha -- nunca arrastrables).
|
// su orden manual), compartidas despues (por fecha -- nunca arrastrables).
|
||||||
export async function findVisibleToUser(userId) {
|
export async function findVisibleToUser(userId) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT n.id, n.user_id, n.content, n.created_at, n.updated_at,
|
`SELECT n.id, n.user_id, n.content, n.edit_version, n.created_at, n.updated_at,
|
||||||
(n.user_id = ?) AS is_owner, u.name AS owner_name,
|
(n.user_id = ?) AS is_owner, u.name AS owner_name,
|
||||||
(SELECT COUNT(*) FROM note_shares ns WHERE ns.note_id = n.id) AS share_count
|
(SELECT COUNT(*) FROM note_shares ns WHERE ns.note_id = n.id) AS share_count
|
||||||
FROM notes n
|
FROM notes n
|
||||||
@@ -29,7 +29,7 @@ export async function reorder(userId, ids) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function findById(id) {
|
export async function findById(id) {
|
||||||
const [rows] = await pool.query('SELECT id, user_id, content, created_at, updated_at FROM notes WHERE id = ?', [id]);
|
const [rows] = await pool.query('SELECT id, user_id, content, edit_version, created_at, updated_at FROM notes WHERE id = ?', [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ export async function create(userId, content) {
|
|||||||
return findById(result.insertId);
|
return findById(result.insertId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id, content) {
|
export async function update(id, content, editVersion) {
|
||||||
await pool.query('UPDATE notes SET content = ? WHERE id = ?', [content, id]);
|
const [result] = await pool.query('UPDATE notes SET content = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [content, id, editVersion]);
|
||||||
return findById(id);
|
return result.affectedRows ? findById(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(id) {
|
export async function remove(id) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import * as usersRepo from '../users/repository.js';
|
import * as usersRepo from '../users/repository.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
|
||||||
// Siempre privadas para editar/borrar/compartir: cada quien solo toca sus propias notas, sin
|
// Siempre privadas para editar/borrar/compartir: cada quien solo toca sus propias notas, sin
|
||||||
@@ -19,11 +20,12 @@ export async function createNote(content, currentUser) {
|
|||||||
return repo.create(currentUser.id, trimmed);
|
return repo.create(currentUser.id, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateNote(id, content, currentUser) {
|
export async function updateNote(id, content, editVersion, currentUser) {
|
||||||
await getOwnNoteOr404(id, currentUser);
|
await getOwnNoteOr404(id, currentUser);
|
||||||
const trimmed = (content || '').trim();
|
const trimmed = (content || '').trim();
|
||||||
if (!trimmed) throw new HttpError(400, 'El contenido es obligatorio');
|
if (!trimmed) throw new HttpError(400, 'El contenido es obligatorio');
|
||||||
return repo.update(id, trimmed);
|
requireEditVersion(editVersion);
|
||||||
|
return requireUpdated(await repo.update(id, trimmed, editVersion));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeNote(id, currentUser) {
|
export async function removeNote(id, currentUser) {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { ok, asyncHandler } from '../../utils/http.js';
|
|||||||
import * as service from './service.js';
|
import * as service from './service.js';
|
||||||
|
|
||||||
export const getProjects = asyncHandler(async (req, res) => {
|
export const getProjects = asyncHandler(async (req, res) => {
|
||||||
const { statusId, priorityId, ownerUserId, trash } = req.query;
|
const { statusId, priorityId, ownerUserId, trash, page, pageSize, dueSoon, progress, q } = req.query;
|
||||||
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true' }, req.currentUser));
|
ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true', page, pageSize, dueSoon: dueSoon === 'true', progress, q }, req.currentUser));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getProjectOptions = asyncHandler(async (req, res) => ok(res, await service.listProjectOptions(req.currentUser)));
|
||||||
|
|
||||||
export const getProject = asyncHandler(async (req, res) => {
|
export const getProject = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.getProject(Number(req.params.id), req.currentUser));
|
ok(res, await service.getProject(Number(req.params.id), req.currentUser));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const LIST_SELECT = `
|
|||||||
p.requesting_department_id, rd.name AS requesting_department_name,
|
p.requesting_department_id, rd.name AS requesting_department_name,
|
||||||
p.responsible_department_id, sd.name AS responsible_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.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, p.deleted_at, p.deleted_by,
|
p.risk_level, p.notes, p.edit_version, p.created_by, p.created_at, p.updated_at, p.deleted_at, p.deleted_by,
|
||||||
(SELECT COUNT(*) FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id <> p.owner_user_id) AS shared_count,
|
(SELECT COUNT(*) FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id <> p.owner_user_id) AS shared_count,
|
||||||
(SELECT COUNT(*) FROM project_members pm2 WHERE pm2.project_id = p.id) AS members_count,
|
(SELECT COUNT(*) FROM project_members pm2 WHERE pm2.project_id = p.id) AS members_count,
|
||||||
(SELECT COUNT(*) FROM activities act WHERE act.project_id = p.id AND act.deleted_at IS NULL) AS activities_total,
|
(SELECT COUNT(*) FROM activities act WHERE act.project_id = p.id AND act.deleted_at IS NULL) AS activities_total,
|
||||||
@@ -38,12 +38,16 @@ function castCounts(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash } = {}) {
|
export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash, page = 1, pageSize = 20, dueSoon, progress, q } = {}) {
|
||||||
const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
|
const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL'];
|
||||||
const params = [];
|
const params = [];
|
||||||
if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); }
|
if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); }
|
||||||
if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); }
|
if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); }
|
||||||
if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); }
|
if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); }
|
||||||
|
if (dueSoon) clauses.push('ps.is_closed = 0 AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)');
|
||||||
|
if (progress === 'hide') clauses.push('p.progress_pct < 100');
|
||||||
|
if (progress === 'only') clauses.push('p.progress_pct >= 100');
|
||||||
|
if (q) { clauses.push('(p.code LIKE ? OR p.name LIKE ? OR u.name LIKE ?)'); params.push(...Array(3).fill(`%${q}%`)); }
|
||||||
// Restringe a proyectos donde el usuario es miembro (compartidos con el, o el propio dueño ya
|
// Restringe a proyectos donde el usuario es miembro (compartidos con el, o el propio dueño ya
|
||||||
// que quien crea un proyecto queda como miembro 'owner' automaticamente -- ver create()).
|
// que quien crea un proyecto queda como miembro 'owner' automaticamente -- ver create()).
|
||||||
if (memberUserId) {
|
if (memberUserId) {
|
||||||
@@ -51,8 +55,18 @@ export async function findAll({ statusId, priorityId, ownerUserId, memberUserId,
|
|||||||
params.push(memberUserId);
|
params.push(memberUserId);
|
||||||
}
|
}
|
||||||
const where = `WHERE ${clauses.join(' AND ')}`;
|
const where = `WHERE ${clauses.join(' AND ')}`;
|
||||||
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params);
|
const [[{ total }]] = await pool.query(`SELECT COUNT(*) AS total FROM projects p JOIN project_statuses ps ON ps.id = p.status_id JOIN users u ON u.id = p.owner_user_id ${where}`, params);
|
||||||
return rows.map(castCounts);
|
const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.updated_at DESC, p.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]);
|
||||||
|
return { items: rows.map(castCounts), total: Number(total), page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findOptions(memberUserId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT p.id, p.code, p.name FROM projects p WHERE p.deleted_at IS NULL
|
||||||
|
${memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : ''}
|
||||||
|
ORDER BY p.name`, memberUserId ? [memberUserId] : []
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findById(id, { includeDeleted = false } = {}) {
|
export async function findById(id, { includeDeleted = false } = {}) {
|
||||||
@@ -62,11 +76,11 @@ export async function findById(id, { includeDeleted = false } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function softDelete(id, userId) {
|
export async function softDelete(id, userId) {
|
||||||
await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL', [userId, id]);
|
await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ? AND deleted_at IS NULL', [userId, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restore(id) {
|
export async function restore(id) {
|
||||||
await pool.query('UPDATE projects SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
|
await pool.query('UPDATE projects SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hardDelete(id) {
|
export async function hardDelete(id) {
|
||||||
@@ -106,21 +120,21 @@ export async function getDefaultStatusId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id, data) {
|
export async function update(id, data) {
|
||||||
await pool.query(
|
const [result] = await pool.query(
|
||||||
`UPDATE projects SET
|
`UPDATE projects SET
|
||||||
name = ?, description = ?, objective = ?, project_type_id = ?, priority_id = ?, status_id = ?,
|
name = ?, description = ?, objective = ?, project_type_id = ?, priority_id = ?, status_id = ?,
|
||||||
owner_user_id = ?, requesting_department_id = ?, responsible_department_id = ?,
|
owner_user_id = ?, requesting_department_id = ?, responsible_department_id = ?,
|
||||||
start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ?
|
start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ?, edit_version = edit_version + 1
|
||||||
WHERE id = ?`,
|
WHERE id = ? AND edit_version = ? AND deleted_at IS NULL`,
|
||||||
[
|
[
|
||||||
data.name, data.description || null, data.objective || null, data.projectTypeId || null,
|
data.name, data.description || null, data.objective || null, data.projectTypeId || null,
|
||||||
data.priorityId, data.statusId, data.ownerUserId,
|
data.priorityId, data.statusId, data.ownerUserId,
|
||||||
data.requestingDepartmentId || null, data.responsibleDepartmentId || null,
|
data.requestingDepartmentId || null, data.responsibleDepartmentId || null,
|
||||||
data.startDate || null, data.estimatedEndDate || null, data.actualEndDate || null,
|
data.startDate || null, data.estimatedEndDate || null, data.actualEndDate || null,
|
||||||
data.riskLevel || 'low', data.notes || null, id,
|
data.riskLevel || 'low', data.notes || null, id, data.editVersion,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return findById(id);
|
return result.affectedRows ? findById(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProgress(id, progressPct) {
|
export async function updateProgress(id, progressPct) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
getProjects, getProject, postProject, putProject, deleteProject, postProjectRestore,
|
getProjects, getProjectOptions, getProject, postProject, putProject, deleteProject, postProjectRestore,
|
||||||
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
|
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
import { getGantt } from '../dashboard/controller.js';
|
import { getGantt } from '../dashboard/controller.js';
|
||||||
@@ -11,6 +11,7 @@ const router = Router();
|
|||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.get('/', getProjects);
|
router.get('/', getProjects);
|
||||||
|
router.get('/options', getProjectOptions);
|
||||||
router.post('/', postProject);
|
router.post('/', postProject);
|
||||||
router.get('/:id', getProject);
|
router.get('/:id', getProject);
|
||||||
router.put('/:id', putProject);
|
router.put('/:id', putProject);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function canReadProject(user, project) {
|
|||||||
// tercer nivel "administrador de proyecto") que se sigue respetando como escritura en filas
|
// tercer nivel "administrador de proyecto") que se sigue respetando como escritura en filas
|
||||||
// viejas, pero ya no se asigna desde la UI.
|
// viejas, pero ya no se asigna desde la UI.
|
||||||
export async function canWriteProject(user, project) {
|
export async function canWriteProject(user, project) {
|
||||||
|
if (user.roleCode === ROLES.VIEWER) return false;
|
||||||
if (FULL_ACCESS_ROLES.includes(user.roleCode)) return true;
|
if (FULL_ACCESS_ROLES.includes(user.roleCode)) return true;
|
||||||
const membership = await repo.getMembership(project.id, user.id);
|
const membership = await repo.getMembership(project.id, user.id);
|
||||||
if (['owner', 'admin', 'write'].includes(membership)) return true;
|
if (['owner', 'admin', 'write'].includes(membership)) return true;
|
||||||
@@ -37,18 +38,22 @@ export function isProjectOwner(user, project) {
|
|||||||
// un admin del proyecto ni un rol global de administracion pueden compartir el proyecto de otra
|
// un admin del proyecto ni un rol global de administracion pueden compartir el proyecto de otra
|
||||||
// persona sin que el dueño lo autorice (acordado con el usuario).
|
// persona sin que el dueño lo autorice (acordado con el usuario).
|
||||||
export function canManageMembers(user, project) {
|
export function canManageMembers(user, project) {
|
||||||
return isProjectOwner(user, project);
|
return user.roleCode !== ROLES.VIEWER && isProjectOwner(user, project);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Borrar el PROYECTO (no su contenido) es exclusivo del dueño o de un rol global de
|
// Borrar el PROYECTO (no su contenido) es exclusivo del dueño o de un rol global de
|
||||||
// administracion -- un colaborador con permiso de Escritura puede borrar actividades/tareas
|
// administracion -- un colaborador con permiso de Escritura puede borrar actividades/tareas
|
||||||
// dentro del proyecto, pero nunca el proyecto en si (acordado con el usuario).
|
// dentro del proyecto, pero nunca el proyecto en si (acordado con el usuario).
|
||||||
export function canDeleteProject(user, project) {
|
export function canDeleteProject(user, project) {
|
||||||
return FULL_ACCESS_ROLES.includes(user.roleCode) || isProjectOwner(user, project);
|
return user.roleCode !== ROLES.VIEWER && (FULL_ACCESS_ROLES.includes(user.roleCode) || isProjectOwner(user, project));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solo el superadmin puede ver la papelera (proyectos eliminados logicamente).
|
// Solo el superadmin puede ver la papelera (proyectos eliminados logicamente).
|
||||||
export async function listProjects(filters, currentUser) {
|
export async function listProjects(filters, currentUser) {
|
||||||
|
const page = Number(filters.page || 1);
|
||||||
|
const pageSize = Number(filters.pageSize || 20);
|
||||||
|
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginacion invalida');
|
||||||
|
filters = { ...filters, page, pageSize, q: String(filters.q || '').trim().slice(0, 100) };
|
||||||
if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) {
|
if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) {
|
||||||
throw new HttpError(403, 'Solo el superadministrador puede ver la papelera');
|
throw new HttpError(403, 'Solo el superadministrador puede ver la papelera');
|
||||||
}
|
}
|
||||||
@@ -59,6 +64,10 @@ export async function listProjects(filters, currentUser) {
|
|||||||
return repo.findAll({ ...filters, memberUserId: currentUser.id });
|
return repo.findAll({ ...filters, memberUserId: currentUser.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listProjectOptions(currentUser) {
|
||||||
|
return repo.findOptions(FULL_ACCESS_ROLES.includes(currentUser.roleCode) ? null : currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Usado por comments/attachments, que cuelgan de 'project' o 'activity' (entity_type/entity_id
|
// Usado por comments/attachments, que cuelgan de 'project' o 'activity' (entity_type/entity_id
|
||||||
// generico, ver BASE_DE_DATOS.md) -- resuelve a que proyecto pertenece la entidad para poder
|
// generico, ver BASE_DE_DATOS.md) -- resuelve a que proyecto pertenece la entidad para poder
|
||||||
// aplicar el mismo chequeo de lectura/escritura por membresia en los dos modulos.
|
// aplicar el mismo chequeo de lectura/escritura por membresia en los dos modulos.
|
||||||
@@ -106,7 +115,9 @@ export async function updateProject(id, payload, currentUser) {
|
|||||||
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
||||||
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
||||||
}
|
}
|
||||||
|
if (!Number.isSafeInteger(payload.editVersion) || payload.editVersion < 1) throw new HttpError(428, 'Falta la version del proyecto');
|
||||||
const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id });
|
const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id });
|
||||||
|
if (!updated) throw new HttpError(409, 'El proyecto cambio en el servidor. Recarga antes de guardar');
|
||||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id });
|
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id });
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ok, asyncHandler } from '../../utils/http.js';
|
|||||||
import * as service from './service.js';
|
import * as service from './service.js';
|
||||||
|
|
||||||
export const getRecurringTasks = asyncHandler(async (req, res) => {
|
export const getRecurringTasks = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.listRecurringTasks());
|
ok(res, await service.listRecurringTasks(req.currentUser));
|
||||||
});
|
});
|
||||||
|
|
||||||
export const postRecurringTask = asyncHandler(async (req, res) => {
|
export const postRecurringTask = asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const LIST_SELECT = `
|
|||||||
r.responsible_user_id, u.name AS responsible_name,
|
r.responsible_user_id, u.name AS responsible_name,
|
||||||
r.interval_unit, r.interval_value, r.next_due_date, r.end_date,
|
r.interval_unit, r.interval_value, r.next_due_date, r.end_date,
|
||||||
r.reminder_days_before, r.reminder_hour, r.is_active, r.last_reminder_sent_at,
|
r.reminder_days_before, r.reminder_hour, r.is_active, r.last_reminder_sent_at,
|
||||||
r.created_by, r.created_at, r.updated_at
|
r.created_by, r.edit_version, r.created_at, r.updated_at
|
||||||
FROM recurring_tasks r
|
FROM recurring_tasks r
|
||||||
LEFT JOIN projects p ON p.id = r.project_id
|
LEFT JOIN projects p ON p.id = r.project_id
|
||||||
LEFT JOIN users u ON u.id = r.responsible_user_id
|
LEFT JOIN users u ON u.id = r.responsible_user_id
|
||||||
@@ -38,19 +38,19 @@ export async function create(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id, data) {
|
export async function update(id, data) {
|
||||||
await pool.query(
|
const [result] = await pool.query(
|
||||||
`UPDATE recurring_tasks SET
|
`UPDATE recurring_tasks SET
|
||||||
name = ?, description = ?, project_id = ?, responsible_user_id = ?, interval_unit = ?,
|
name = ?, description = ?, project_id = ?, responsible_user_id = ?, interval_unit = ?,
|
||||||
interval_value = ?, next_due_date = ?, end_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ?
|
interval_value = ?, next_due_date = ?, end_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ?, edit_version = edit_version + 1
|
||||||
WHERE id = ?`,
|
WHERE id = ? AND edit_version = ?`,
|
||||||
[
|
[
|
||||||
data.name, data.description || null, data.projectId || null, data.responsibleUserId || null,
|
data.name, data.description || null, data.projectId || null, data.responsibleUserId || null,
|
||||||
data.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null,
|
data.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null,
|
||||||
data.reminderDaysBefore ?? 7, data.reminderHour ?? 9,
|
data.reminderDaysBefore ?? 7, data.reminderHour ?? 9,
|
||||||
data.isActive === false ? 0 : 1, id,
|
data.isActive === false ? 0 : 1, id, data.editVersion,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return findById(id);
|
return result.affectedRows ? findById(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(id) {
|
export async function remove(id) {
|
||||||
@@ -69,12 +69,12 @@ export async function advanceCycle(id) {
|
|||||||
const unit = SQL_UNIT[row.interval_unit] || 'MONTH';
|
const unit = SQL_UNIT[row.interval_unit] || 'MONTH';
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE recurring_tasks
|
`UPDATE recurring_tasks
|
||||||
SET next_due_date = DATE_ADD(next_due_date, INTERVAL ? ${unit}), last_reminder_sent_at = NULL
|
SET next_due_date = DATE_ADD(next_due_date, INTERVAL ? ${unit}), last_reminder_sent_at = NULL, edit_version = edit_version + 1
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[row.interval_value, id]
|
[row.interval_value, id]
|
||||||
);
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE recurring_tasks SET is_active = 0
|
`UPDATE recurring_tasks SET is_active = 0, edit_version = edit_version + 1
|
||||||
WHERE id = ? AND end_date IS NOT NULL AND next_due_date > end_date`,
|
WHERE id = ? AND end_date IS NOT NULL AND next_due_date > end_date`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import * as repo from './repository.js';
|
|||||||
import * as tasksRepo from '../tasks/repository.js';
|
import * as tasksRepo from '../tasks/repository.js';
|
||||||
import * as activitiesRepo from '../activities/repository.js';
|
import * as activitiesRepo from '../activities/repository.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { sendMail } from '../../utils/mailer.js';
|
import { sendMail } from '../../utils/mailer.js';
|
||||||
|
import { canReadProject, canWriteProject } from '../projects/service.js';
|
||||||
|
import * as projectsRepo from '../projects/repository.js';
|
||||||
|
import { escapeHtml } from '../../utils/emailHtml.js';
|
||||||
|
|
||||||
const INTERVAL_UNITS = ['days', 'months', 'years'];
|
const INTERVAL_UNITS = ['days', 'months', 'years'];
|
||||||
|
|
||||||
@@ -20,9 +24,33 @@ function validate(payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listRecurringTasks = () => repo.findAll();
|
const FULL = ['superadmin', 'admin', 'it_manager'];
|
||||||
|
function canOwn(user, task) { return FULL.includes(user.roleCode) || task.created_by === user.id; }
|
||||||
|
async function checkProject(projectId, user, write) {
|
||||||
|
if (!projectId) return;
|
||||||
|
const project = await projectsRepo.findById(projectId);
|
||||||
|
if (!project || !(await (write ? canWriteProject : canReadProject)(user, project))) throw new HttpError(404, 'Proyecto no encontrado');
|
||||||
|
}
|
||||||
|
function checkWritable(user, task) {
|
||||||
|
if (user.roleCode === 'viewer' || (task && !canOwn(user, task))) throw new HttpError(404, 'Tarea recurrente no encontrada');
|
||||||
|
}
|
||||||
|
export async function listRecurringTasks(user) {
|
||||||
|
const tasks = await repo.findAll();
|
||||||
|
const result = [];
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!canOwn(user, task)) continue;
|
||||||
|
if (task.project_id) {
|
||||||
|
const project = await projectsRepo.findById(task.project_id);
|
||||||
|
if (!project || !(await canReadProject(user, project))) continue;
|
||||||
|
}
|
||||||
|
result.push(task);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRecurringTask(payload, currentUser) {
|
export async function createRecurringTask(payload, currentUser) {
|
||||||
|
checkWritable(currentUser);
|
||||||
|
await checkProject(payload.projectId, currentUser, true);
|
||||||
validate(payload);
|
validate(payload);
|
||||||
const task = await repo.create({ ...payload, createdBy: currentUser.id });
|
const task = await repo.create({ ...payload, createdBy: currentUser.id });
|
||||||
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'recurring_task', entityId: task.id });
|
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'recurring_task', entityId: task.id });
|
||||||
@@ -32,8 +60,12 @@ export async function createRecurringTask(payload, currentUser) {
|
|||||||
export async function updateRecurringTask(id, payload, currentUser) {
|
export async function updateRecurringTask(id, payload, currentUser) {
|
||||||
const existing = await repo.findById(id);
|
const existing = await repo.findById(id);
|
||||||
if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada');
|
if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada');
|
||||||
|
checkWritable(currentUser, existing);
|
||||||
|
await checkProject(existing.project_id, currentUser, true);
|
||||||
|
await checkProject(payload.projectId, currentUser, true);
|
||||||
validate(payload);
|
validate(payload);
|
||||||
const task = await repo.update(id, payload);
|
requireEditVersion(payload.editVersion);
|
||||||
|
const task = requireUpdated(await repo.update(id, payload));
|
||||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'recurring_task', entityId: id });
|
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'recurring_task', entityId: id });
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
@@ -41,6 +73,8 @@ export async function updateRecurringTask(id, payload, currentUser) {
|
|||||||
export async function removeRecurringTask(id, currentUser) {
|
export async function removeRecurringTask(id, currentUser) {
|
||||||
const existing = await repo.findById(id);
|
const existing = await repo.findById(id);
|
||||||
if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada');
|
if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada');
|
||||||
|
checkWritable(currentUser, existing);
|
||||||
|
await checkProject(existing.project_id, currentUser, true);
|
||||||
await repo.remove(id);
|
await repo.remove(id);
|
||||||
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'recurring_task', entityId: id });
|
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'recurring_task', entityId: id });
|
||||||
}
|
}
|
||||||
@@ -50,6 +84,8 @@ export async function removeRecurringTask(id, currentUser) {
|
|||||||
export async function completeCycle(id, currentUser) {
|
export async function completeCycle(id, currentUser) {
|
||||||
const existing = await repo.findById(id);
|
const existing = await repo.findById(id);
|
||||||
if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada');
|
if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada');
|
||||||
|
checkWritable(currentUser, existing);
|
||||||
|
await checkProject(existing.project_id, currentUser, true);
|
||||||
const task = await repo.advanceCycle(id);
|
const task = await repo.advanceCycle(id);
|
||||||
await logAudit({ userId: currentUser.id, action: 'complete_cycle', entityType: 'recurring_task', entityId: id });
|
await logAudit({ userId: currentUser.id, action: 'complete_cycle', entityType: 'recurring_task', entityId: id });
|
||||||
return task;
|
return task;
|
||||||
@@ -59,10 +95,12 @@ export async function completeCycle(id, currentUser) {
|
|||||||
// actividad NO se borra (queda como referencia de que ya se hizo esta vez), simplemente se crea
|
// actividad NO se borra (queda como referencia de que ya se hizo esta vez), simplemente se crea
|
||||||
// la tarea recurrente enlazada al proyecto de esa actividad para tener contexto.
|
// la tarea recurrente enlazada al proyecto de esa actividad para tener contexto.
|
||||||
export async function promoteFromTask(taskId, payload, currentUser) {
|
export async function promoteFromTask(taskId, payload, currentUser) {
|
||||||
|
checkWritable(currentUser);
|
||||||
const sourceTask = await tasksRepo.findById(taskId);
|
const sourceTask = await tasksRepo.findById(taskId);
|
||||||
if (!sourceTask) throw new HttpError(404, 'Tarea no encontrada');
|
if (!sourceTask) throw new HttpError(404, 'Tarea no encontrada');
|
||||||
const activity = await activitiesRepo.findById(sourceTask.activity_id);
|
const activity = await activitiesRepo.findById(sourceTask.activity_id);
|
||||||
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
|
if (!activity) throw new HttpError(404, 'Actividad no encontrada');
|
||||||
|
await checkProject(activity.project_id, currentUser, true);
|
||||||
|
|
||||||
const merged = {
|
const merged = {
|
||||||
name: payload.name || sourceTask.name,
|
name: payload.name || sourceTask.name,
|
||||||
@@ -94,18 +132,18 @@ export async function sendDueReminders() {
|
|||||||
const to = item.responsible_email || item.creator_email;
|
const to = item.responsible_email || item.creator_email;
|
||||||
const recipientName = item.responsible_name || item.creator_name;
|
const recipientName = item.responsible_name || item.creator_name;
|
||||||
try {
|
try {
|
||||||
await sendMail({
|
const result = await sendMail({
|
||||||
to,
|
to,
|
||||||
subject: `Recordatorio: ${item.name} vence el ${item.next_due_date}`,
|
subject: `Recordatorio: ${item.name} vence el ${item.next_due_date}`,
|
||||||
html: `<p>Hola ${recipientName},</p>
|
html: `<p>Hola ${escapeHtml(recipientName)},</p>
|
||||||
<p>La tarea recurrente <strong>${item.name}</strong>${item.project_name ? ` (proyecto: ${item.project_name})` : ''}
|
<p>La tarea recurrente <strong>${escapeHtml(item.name)}</strong>${item.project_name ? ` (proyecto: ${escapeHtml(item.project_name)})` : ''}
|
||||||
vence el <strong>${item.next_due_date}</strong>.</p>
|
vence el <strong>${escapeHtml(item.next_due_date)}</strong>.</p>
|
||||||
<p>Entra a project.linkedcorp.net → Tareas recurrentes para revisarla.</p>`,
|
<p>Entra a project.linkedcorp.net → Tareas recurrentes para revisarla.</p>`,
|
||||||
});
|
});
|
||||||
|
if (result.sent) await repo.markReminderSent(item.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[recurring-tasks] error enviando recordatorio', item.id, err.message);
|
console.error('[recurring-tasks] error enviando recordatorio', item.id, err.message);
|
||||||
}
|
}
|
||||||
await repo.markReminderSent(item.id);
|
|
||||||
}
|
}
|
||||||
return due.length;
|
return due.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { ok, asyncHandler } from '../../utils/http.js';
|
|||||||
import * as service from './service.js';
|
import * as service from './service.js';
|
||||||
|
|
||||||
export const getSearch = asyncHandler(async (req, res) => {
|
export const getSearch = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.search(req.query.q, req.query.projectId));
|
ok(res, await service.search(req.query.q, req.query.projectId, req.currentUser));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ const RESULT_LIMIT = 20;
|
|||||||
|
|
||||||
// Busqueda global (menu Proyectos): proyectos, actividades (con su proyecto) y tareas (con su
|
// Busqueda global (menu Proyectos): proyectos, actividades (con su proyecto) y tareas (con su
|
||||||
// actividad y proyecto) — para armar la migaja de pan "Proyecto - Actividad - Tarea" en el cliente.
|
// actividad y proyecto) — para armar la migaja de pan "Proyecto - Actividad - Tarea" en el cliente.
|
||||||
export async function searchGlobal(term) {
|
export async function searchGlobal(term, user) {
|
||||||
const like = `%${term}%`;
|
const like = `%${term}%`;
|
||||||
|
const fullAccess = ['superadmin', 'admin', 'it_manager'].includes(user.roleCode);
|
||||||
|
const scope = fullAccess ? '' : ' AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)';
|
||||||
|
const scopeParams = fullAccess ? [] : [user.id];
|
||||||
|
|
||||||
const [projects] = await pool.query(
|
const [projects] = await pool.query(
|
||||||
`SELECT id, code, name FROM projects
|
`SELECT p.id, p.code, p.name FROM projects p
|
||||||
WHERE deleted_at IS NULL AND (code LIKE ? OR name LIKE ?)
|
WHERE p.deleted_at IS NULL AND (p.code LIKE ? OR p.name LIKE ?)${scope}
|
||||||
ORDER BY name LIMIT ?`,
|
ORDER BY name LIMIT ?`,
|
||||||
[like, like, RESULT_LIMIT]
|
[like, like, ...scopeParams, RESULT_LIMIT]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [activities] = await pool.query(
|
const [activities] = await pool.query(
|
||||||
@@ -19,9 +22,9 @@ export async function searchGlobal(term) {
|
|||||||
p.id AS project_id, p.code AS project_code, p.name AS project_name
|
p.id AS project_id, p.code AS project_code, p.name AS project_name
|
||||||
FROM activities a
|
FROM activities a
|
||||||
JOIN projects p ON p.id = a.project_id
|
JOIN projects p ON p.id = a.project_id
|
||||||
WHERE p.deleted_at IS NULL AND (a.code LIKE ? OR a.name LIKE ?)
|
WHERE p.deleted_at IS NULL AND a.deleted_at IS NULL AND (a.code LIKE ? OR a.name LIKE ?)${scope}
|
||||||
ORDER BY a.name LIMIT ?`,
|
ORDER BY a.name LIMIT ?`,
|
||||||
[like, like, RESULT_LIMIT]
|
[like, like, ...scopeParams, RESULT_LIMIT]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [tasks] = await pool.query(
|
const [tasks] = await pool.query(
|
||||||
@@ -31,9 +34,9 @@ export async function searchGlobal(term) {
|
|||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN activities a ON a.id = t.activity_id
|
JOIN activities a ON a.id = t.activity_id
|
||||||
JOIN projects p ON p.id = a.project_id
|
JOIN projects p ON p.id = a.project_id
|
||||||
WHERE p.deleted_at IS NULL AND t.name LIKE ?
|
WHERE p.deleted_at IS NULL AND a.deleted_at IS NULL AND t.deleted_at IS NULL AND t.name LIKE ?${scope}
|
||||||
ORDER BY t.name LIMIT ?`,
|
ORDER BY t.name LIMIT ?`,
|
||||||
[like, RESULT_LIMIT]
|
[like, ...scopeParams, RESULT_LIMIT]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { projects, activities, tasks };
|
return { projects, activities, tasks };
|
||||||
@@ -46,7 +49,7 @@ export async function searchInProject(projectId, term) {
|
|||||||
|
|
||||||
const [activities] = await pool.query(
|
const [activities] = await pool.query(
|
||||||
`SELECT id, code, name FROM activities
|
`SELECT id, code, name FROM activities
|
||||||
WHERE project_id = ? AND (code LIKE ? OR name LIKE ?)
|
WHERE project_id = ? AND deleted_at IS NULL AND (code LIKE ? OR name LIKE ?)
|
||||||
ORDER BY name LIMIT ?`,
|
ORDER BY name LIMIT ?`,
|
||||||
[projectId, like, like, RESULT_LIMIT]
|
[projectId, like, like, RESULT_LIMIT]
|
||||||
);
|
);
|
||||||
@@ -55,7 +58,7 @@ export async function searchInProject(projectId, term) {
|
|||||||
`SELECT t.id, t.name, a.id AS activity_id, a.code AS activity_code, a.name AS activity_name
|
`SELECT t.id, t.name, a.id AS activity_id, a.code AS activity_code, a.name AS activity_name
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN activities a ON a.id = t.activity_id
|
JOIN activities a ON a.id = t.activity_id
|
||||||
WHERE a.project_id = ? AND t.name LIKE ?
|
WHERE a.project_id = ? AND a.deleted_at IS NULL AND t.deleted_at IS NULL AND t.name LIKE ?
|
||||||
ORDER BY t.name LIMIT ?`,
|
ORDER BY t.name LIMIT ?`,
|
||||||
[projectId, like, RESULT_LIMIT]
|
[projectId, like, RESULT_LIMIT]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import { getProject } from '../projects/service.js';
|
||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
|
||||||
const MIN_LENGTH = 2;
|
const MIN_LENGTH = 2;
|
||||||
|
|
||||||
export async function search(term, projectId) {
|
export async function search(term, projectId, currentUser) {
|
||||||
const q = (term || '').trim();
|
const q = (term || '').trim();
|
||||||
|
if (projectId) {
|
||||||
|
if (!Number.isSafeInteger(Number(projectId)) || Number(projectId) < 1) throw new HttpError(400, 'projectId invalido');
|
||||||
|
await getProject(Number(projectId), currentUser);
|
||||||
|
}
|
||||||
if (q.length < MIN_LENGTH) {
|
if (q.length < MIN_LENGTH) {
|
||||||
return projectId ? { activities: [], tasks: [] } : { projects: [], activities: [], tasks: [] };
|
return projectId ? { activities: [], tasks: [] } : { projects: [], activities: [], tasks: [] };
|
||||||
}
|
}
|
||||||
return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q);
|
return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q, currentUser);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,27 @@ export async function setMany(entries) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// La fila de version serializa las escrituras de cada panel y los valores se cambian
|
||||||
|
// en la misma transaccion. Una version antigua no modifica ninguna clave.
|
||||||
|
export async function setManyIfVersion(entries, versionKey, editVersion) {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
const [result] = await conn.query(
|
||||||
|
'UPDATE system_settings SET `value` = CAST(CAST(`value` AS UNSIGNED) + 1 AS CHAR) WHERE `key` = ? AND CAST(`value` AS UNSIGNED) = ?',
|
||||||
|
[versionKey, editVersion]
|
||||||
|
);
|
||||||
|
if (!result.affectedRows) { await conn.rollback(); return false; }
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
await conn.query('INSERT INTO system_settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)', [key, value]);
|
||||||
|
}
|
||||||
|
await conn.commit();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import nodemailer from 'nodemailer';
|
|||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { encrypt, decrypt } from '../../utils/crypto.js';
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
|
||||||
const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_pass_enc'];
|
const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_pass_enc'];
|
||||||
@@ -12,8 +13,9 @@ const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_pas
|
|||||||
const stripSpaces = (s) => (s || '').replace(/\s+/g, '');
|
const stripSpaces = (s) => (s || '').replace(/\s+/g, '');
|
||||||
|
|
||||||
export async function getSmtpConfig() {
|
export async function getSmtpConfig() {
|
||||||
const values = await repo.getAll(SMTP_KEYS);
|
const values = await repo.getAll([...SMTP_KEYS, 'smtp_edit_version']);
|
||||||
return {
|
return {
|
||||||
|
edit_version: Number(values.smtp_edit_version),
|
||||||
host: values.smtp_host || '',
|
host: values.smtp_host || '',
|
||||||
port: values.smtp_port ? Number(values.smtp_port) : 587,
|
port: values.smtp_port ? Number(values.smtp_port) : 587,
|
||||||
user: values.smtp_user || '',
|
user: values.smtp_user || '',
|
||||||
@@ -46,6 +48,7 @@ export async function getSmtpConfigInternal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSmtpConfig(payload, actorId) {
|
export async function updateSmtpConfig(payload, actorId) {
|
||||||
|
requireEditVersion(payload.editVersion);
|
||||||
const host = stripSpaces(payload.host);
|
const host = stripSpaces(payload.host);
|
||||||
const user = stripSpaces(payload.user);
|
const user = stripSpaces(payload.user);
|
||||||
const from = stripSpaces(payload.from);
|
const from = stripSpaces(payload.from);
|
||||||
@@ -57,7 +60,7 @@ export async function updateSmtpConfig(payload, actorId) {
|
|||||||
smtp_from: from || user,
|
smtp_from: from || user,
|
||||||
};
|
};
|
||||||
if (payload.pass) entries.smtp_pass_enc = encrypt(payload.pass);
|
if (payload.pass) entries.smtp_pass_enc = encrypt(payload.pass);
|
||||||
await repo.setMany(entries);
|
requireUpdated(await repo.setManyIfVersion(entries, 'smtp_edit_version', payload.editVersion));
|
||||||
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:smtp', entityId: null });
|
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:smtp', entityId: null });
|
||||||
return getSmtpConfig();
|
return getSmtpConfig();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { pool } from '../../config/db.js';
|
|||||||
|
|
||||||
const LIST_SELECT = `
|
const LIST_SELECT = `
|
||||||
SELECT t.id, t.activity_id, t.name, t.responsible_user_id, u.name AS responsible_name,
|
SELECT t.id, t.activity_id, t.name, t.responsible_user_id, u.name AS responsible_name,
|
||||||
t.is_done, t.due_date, t.sort_order, t.created_at
|
t.is_done, t.due_date, t.edit_version, t.sort_order, t.created_at
|
||||||
FROM tasks t LEFT JOIN users u ON u.id = t.responsible_user_id
|
FROM tasks t LEFT JOIN users u ON u.id = t.responsible_user_id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -44,30 +44,30 @@ export async function create(activityId, { name, responsibleUserId, dueDate }) {
|
|||||||
return findById(result.insertId);
|
return findById(result.insertId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id, { name, responsibleUserId, dueDate }) {
|
export async function update(id, { name, responsibleUserId, dueDate, editVersion }) {
|
||||||
await pool.query('UPDATE tasks SET name = ?, responsible_user_id = ?, due_date = ? WHERE id = ?', [
|
const [result] = await pool.query('UPDATE tasks SET name = ?, responsible_user_id = ?, due_date = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ? AND deleted_at IS NULL', [
|
||||||
name, responsibleUserId || null, dueDate || null, id,
|
name, responsibleUserId || null, dueDate || null, id, editVersion,
|
||||||
]);
|
]);
|
||||||
return findById(id);
|
return result.affectedRows ? findById(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setDone(id, isDone) {
|
export async function setDone(id, isDone) {
|
||||||
await pool.query('UPDATE tasks SET is_done = ? WHERE id = ?', [isDone ? 1 : 0, id]);
|
await pool.query('UPDATE tasks SET is_done = ?, edit_version = edit_version + 1 WHERE id = ?', [isDone ? 1 : 0, id]);
|
||||||
return findById(id);
|
return findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function softDelete(id, userId) {
|
export async function softDelete(id, userId) {
|
||||||
await pool.query('UPDATE tasks SET deleted_at = NOW(), deleted_by = ? WHERE id = ?', [userId, id]);
|
await pool.query('UPDATE tasks SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ?', [userId, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restore(id) {
|
export async function restore(id) {
|
||||||
await pool.query('UPDATE tasks SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]);
|
await pool.query('UPDATE tasks SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usado al convertir una actividad en proyecto: sus tareas pasan a la actividad nueva que se crea
|
// Usado al convertir una actividad en proyecto: sus tareas pasan a la actividad nueva que se crea
|
||||||
// para contenerlas. sort_order vuelve a 0 ("nunca reordenado a mano") en el destino.
|
// para contenerlas. sort_order vuelve a 0 ("nunca reordenado a mano") en el destino.
|
||||||
export async function moveAll(oldActivityId, newActivityId) {
|
export async function moveAll(oldActivityId, newActivityId) {
|
||||||
await pool.query('UPDATE tasks SET activity_id = ?, sort_order = 0 WHERE activity_id = ?', [newActivityId, oldActivityId]);
|
await pool.query('UPDATE tasks SET activity_id = ?, sort_order = 0, edit_version = edit_version + 1 WHERE activity_id = ?', [newActivityId, oldActivityId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countsByActivity(activityId) {
|
export async function countsByActivity(activityId) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as projectsRepo from '../projects/repository.js';
|
|||||||
import { canWriteActivity, recalcFromTasks } from '../activities/service.js';
|
import { canWriteActivity, recalcFromTasks } from '../activities/service.js';
|
||||||
import { canReadProject } from '../projects/service.js';
|
import { canReadProject } from '../projects/service.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
|
||||||
async function requireActivityAccess(activityId, currentUser) {
|
async function requireActivityAccess(activityId, currentUser) {
|
||||||
@@ -44,7 +45,8 @@ export async function updateTask(id, payload, currentUser) {
|
|||||||
if (!task) throw new HttpError(404, 'Tarea no encontrada');
|
if (!task) throw new HttpError(404, 'Tarea no encontrada');
|
||||||
await requireActivityAccess(task.activity_id, currentUser);
|
await requireActivityAccess(task.activity_id, currentUser);
|
||||||
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||||
const updated = await repo.update(id, payload);
|
requireEditVersion(payload.editVersion);
|
||||||
|
const updated = requireUpdated(await repo.update(id, payload));
|
||||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'task', entityId: id });
|
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'task', entityId: id });
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,22 @@ export const getUsers = asyncHandler(async (req, res) => ok(res, await service.l
|
|||||||
export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles()));
|
export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles()));
|
||||||
|
|
||||||
export const postUser = asyncHandler(async (req, res) => {
|
export const postUser = asyncHandler(async (req, res) => {
|
||||||
const user = await service.createUser(req.body, req.currentUser.id);
|
const user = await service.createUser(req.body, req.currentUser);
|
||||||
ok(res, user, 201);
|
ok(res, user, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const putUser = asyncHandler(async (req, res) => {
|
export const putUser = asyncHandler(async (req, res) => {
|
||||||
const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser.id);
|
const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser);
|
||||||
ok(res, user);
|
ok(res, user);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const patchUserActive = asyncHandler(async (req, res) => {
|
export const patchUserActive = asyncHandler(async (req, res) => {
|
||||||
const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser.id);
|
const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser);
|
||||||
ok(res, user);
|
ok(res, user);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const postApproveUser = asyncHandler(async (req, res) => {
|
export const postApproveUser = asyncHandler(async (req, res) => {
|
||||||
const user = await service.approveUser(Number(req.params.id), req.body, req.currentUser.id);
|
const user = await service.approveUser(Number(req.params.id), req.body, req.currentUser);
|
||||||
ok(res, user);
|
ok(res, user);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { pool } from '../../config/db.js';
|
|||||||
export async function findAll() {
|
export async function findAll() {
|
||||||
const [rows] = await pool.query(
|
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,
|
`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.status, u.created_at
|
u.department_id, d.name AS department_name, u.position, u.is_active, u.status, u.edit_version, u.created_at
|
||||||
FROM users u
|
FROM users u
|
||||||
JOIN roles r ON r.id = u.role_id
|
JOIN roles r ON r.id = u.role_id
|
||||||
LEFT JOIN departments d ON d.id = u.department_id
|
LEFT JOIN departments d ON d.id = u.department_id
|
||||||
@@ -14,7 +14,7 @@ export async function findAll() {
|
|||||||
|
|
||||||
export async function findById(id) {
|
export async function findById(id) {
|
||||||
const [rows] = await pool.query(
|
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, u.status
|
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active, u.status, u.edit_version
|
||||||
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
|
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
@@ -51,26 +51,26 @@ export async function create({ name, email, passwordHash, roleId, departmentId,
|
|||||||
return findById(result.insertId);
|
return findById(result.insertId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id, { name, roleId, departmentId, position }) {
|
export async function update(id, { name, roleId, departmentId, position, editVersion }) {
|
||||||
await pool.query(
|
const [result] = await pool.query(
|
||||||
`UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ? WHERE id = ?`,
|
`UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?`,
|
||||||
[name, roleId, departmentId || null, position || null, id]
|
[name, roleId, departmentId || null, position || null, id, editVersion]
|
||||||
);
|
);
|
||||||
return findById(id);
|
return result.affectedRows ? findById(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setActive(id, isActive) {
|
export async function setActive(id, isActive) {
|
||||||
await pool.query('UPDATE users SET is_active = ? WHERE id = ?', [isActive ? 1 : 0, id]);
|
await pool.query('UPDATE users SET is_active = ?, edit_version = edit_version + 1 WHERE id = ?', [isActive ? 1 : 0, id]);
|
||||||
return findById(id);
|
return findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function approve(id, { roleId, departmentId }) {
|
export async function approve(id, { roleId, departmentId, editVersion }) {
|
||||||
await pool.query(
|
const [result] = await pool.query(
|
||||||
`UPDATE users SET status = 'active', is_active = 1, role_id = COALESCE(?, role_id), department_id = COALESCE(?, department_id)
|
`UPDATE users SET status = 'active', is_active = 1, edit_version = edit_version + 1, role_id = COALESCE(?, role_id), department_id = COALESCE(?, department_id)
|
||||||
WHERE id = ?`,
|
WHERE id = ? AND edit_version = ? AND status = 'pending_approval'`,
|
||||||
[roleId || null, departmentId || null, id]
|
[roleId || null, departmentId || null, id, editVersion]
|
||||||
);
|
);
|
||||||
return findById(id);
|
return result.affectedRows ? findById(id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(id) {
|
export async function remove(id) {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import crypto from 'node:crypto';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { sendMail } from '../../utils/mailer.js';
|
import { sendMail } from '../../utils/mailer.js';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
|
import { invitationEmail, verificationEmail } from '../../utils/emailHtml.js';
|
||||||
|
|
||||||
export const listUsers = () => repo.findAll();
|
export const listUsers = () => repo.findAll();
|
||||||
export const listRoles = () => repo.listRoles();
|
export const listRoles = () => repo.listRoles();
|
||||||
@@ -28,41 +30,56 @@ export async function inviteUser(email, actorId) {
|
|||||||
await sendMail({
|
await sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Te invitaron a project.linkedcorp.net',
|
subject: 'Te invitaron a project.linkedcorp.net',
|
||||||
html: `<p>Te agregaron como miembro de un proyecto en project.linkedcorp.net.</p>
|
html: invitationEmail(inviteUrl),
|
||||||
<p>Completa tu registro para acceder (elige tu nombre y una clave):</p>
|
|
||||||
<p><a href="${inviteUrl}">${inviteUrl}</a></p><p>Este enlace vence en 7 dias.</p>`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { userId, invited: true };
|
return { userId, invited: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(payload, actorId) {
|
async function checkRole(roleId, actor) {
|
||||||
|
const role = (await repo.listRoles()).find((item) => item.id === Number(roleId));
|
||||||
|
if (!role) throw new HttpError(400, 'Rol invalido');
|
||||||
|
if (role.code === 'superadmin' && actor.roleCode !== 'superadmin') throw new HttpError(403, 'No puedes asignar ese rol');
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkTarget(target, actor) {
|
||||||
|
if (target.role_code === 'superadmin' && actor.roleCode !== 'superadmin') {
|
||||||
|
throw new HttpError(403, 'No puedes modificar un superadministrador');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(payload, actor) {
|
||||||
if (!payload.name || !payload.email || !payload.password || !payload.roleId) {
|
if (!payload.name || !payload.email || !payload.password || !payload.roleId) {
|
||||||
throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios');
|
throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios');
|
||||||
}
|
}
|
||||||
const existing = await repo.findByEmail(payload.email);
|
const existing = await repo.findByEmail(payload.email);
|
||||||
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
||||||
|
await checkRole(payload.roleId, actor);
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(payload.password, 12);
|
const passwordHash = await bcrypt.hash(payload.password, 12);
|
||||||
const user = await repo.create({ ...payload, passwordHash });
|
const user = await repo.create({ ...payload, passwordHash });
|
||||||
await logAudit({ userId: actorId, action: 'create', entityType: 'user', entityId: user.id });
|
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(id, payload, actorId) {
|
export async function updateUser(id, payload, actor) {
|
||||||
const existing = await repo.findById(id);
|
const existing = await repo.findById(id);
|
||||||
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
||||||
const user = await repo.update(id, payload);
|
checkTarget(existing, actor);
|
||||||
await logAudit({ userId: actorId, action: 'update', entityType: 'user', entityId: id });
|
await checkRole(payload.roleId, actor);
|
||||||
|
requireEditVersion(payload.editVersion);
|
||||||
|
const user = requireUpdated(await repo.update(id, payload));
|
||||||
|
await logAudit({ userId: actor.id, action: 'update', entityType: 'user', entityId: id });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setUserActive(id, isActive, actorId) {
|
export async function setUserActive(id, isActive, actor) {
|
||||||
const existing = await repo.findById(id);
|
const existing = await repo.findById(id);
|
||||||
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
||||||
|
checkTarget(existing, actor);
|
||||||
const user = await repo.setActive(id, isActive);
|
const user = await repo.setActive(id, isActive);
|
||||||
await logAudit({
|
await logAudit({
|
||||||
userId: actorId,
|
userId: actor.id,
|
||||||
action: isActive ? 'activate' : 'deactivate',
|
action: isActive ? 'activate' : 'deactivate',
|
||||||
entityType: 'user',
|
entityType: 'user',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
@@ -70,12 +87,16 @@ export async function setUserActive(id, isActive, actorId) {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function approveUser(id, payload, actorId) {
|
export async function approveUser(id, payload, actor) {
|
||||||
const existing = await repo.findById(id);
|
const existing = await repo.findById(id);
|
||||||
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
||||||
|
checkTarget(existing, actor);
|
||||||
|
requireEditVersion(payload.editVersion);
|
||||||
|
if (existing.edit_version !== payload.editVersion) requireUpdated(null);
|
||||||
if (existing.status !== 'pending_approval') throw new HttpError(400, 'El usuario no esta pendiente de aprobacion');
|
if (existing.status !== 'pending_approval') throw new HttpError(400, 'El usuario no esta pendiente de aprobacion');
|
||||||
const user = await repo.approve(id, payload);
|
if (payload.roleId) await checkRole(payload.roleId, actor);
|
||||||
await logAudit({ userId: actorId, action: 'approve', entityType: 'user', entityId: id });
|
const user = requireUpdated(await repo.approve(id, payload));
|
||||||
|
await logAudit({ userId: actor.id, action: 'approve', entityType: 'user', entityId: id });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +120,7 @@ export async function resendUserEmail(id, actorId) {
|
|||||||
await sendMail({
|
await sendMail({
|
||||||
to: existing.email,
|
to: existing.email,
|
||||||
subject: 'Te invitaron a project.linkedcorp.net',
|
subject: 'Te invitaron a project.linkedcorp.net',
|
||||||
html: `<p>Te agregaron como miembro de un proyecto en project.linkedcorp.net.</p>
|
html: invitationEmail(inviteUrl),
|
||||||
<p>Completa tu registro para acceder (elige tu nombre y una clave):</p>
|
|
||||||
<p><a href="${inviteUrl}">${inviteUrl}</a></p><p>Este enlace vence en 7 dias.</p>`,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await repo.refreshVerificationToken(id, token, 24);
|
await repo.refreshVerificationToken(id, token, 24);
|
||||||
@@ -109,7 +128,7 @@ export async function resendUserEmail(id, actorId) {
|
|||||||
await sendMail({
|
await sendMail({
|
||||||
to: existing.email,
|
to: existing.email,
|
||||||
subject: 'Confirma tu correo — project.linkedcorp.net',
|
subject: 'Confirma tu correo — project.linkedcorp.net',
|
||||||
html: `<p>Hola ${existing.name},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>Este enlace vence en 24 horas.</p>`,
|
html: verificationEmail(existing.name, verifyUrl),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { encrypt, decrypt } from '../../utils/crypto.js';
|
|||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { sendMail } from '../../utils/mailer.js';
|
import { sendMail } from '../../utils/mailer.js';
|
||||||
|
import { escapeHtml } from '../../utils/emailHtml.js';
|
||||||
|
|
||||||
const PIN_RE = /^\d{4}$/;
|
const PIN_RE = /^\d{4}$/;
|
||||||
const VAULT_TOKEN_EXPIRES = '30m';
|
const VAULT_TOKEN_EXPIRES = '30m';
|
||||||
@@ -272,11 +273,11 @@ export async function forgotPin(currentUser) {
|
|||||||
await sendMail({
|
await sendMail({
|
||||||
to: currentUser.email,
|
to: currentUser.email,
|
||||||
subject: 'Restablecer PIN de Contraseñas — project.linkedcorp.net',
|
subject: 'Restablecer PIN de Contraseñas — project.linkedcorp.net',
|
||||||
html: `<p>Hola ${currentUser.name},</p>
|
html: `<p>Hola ${escapeHtml(currentUser.name)},</p>
|
||||||
<p>Pediste restablecer el PIN de acceso a tu repositorio de Contraseñas. Esto solo cambia
|
<p>Pediste restablecer el PIN de acceso a tu repositorio de Contraseñas. Esto solo cambia
|
||||||
el PIN -- tus contraseñas guardadas no se tocan ni se pierden.</p>
|
el PIN -- tus contraseñas guardadas no se tocan ni se pierden.</p>
|
||||||
<p>Elegi un PIN nuevo en este enlace (vence en 1 hora):</p>
|
<p>Elegi un PIN nuevo en este enlace (vence en 1 hora):</p>
|
||||||
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
<p><a href="${escapeHtml(resetUrl)}">${escapeHtml(resetUrl)}</a></p>
|
||||||
<p>Si no pediste esto, ignora el correo.</p>`,
|
<p>Si no pediste esto, ignora el correo.</p>`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async function run() {
|
|||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active)
|
`INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active)
|
||||||
VALUES (?, ?, ?, ?, NULL, NULL, 1)
|
VALUES (?, ?, ?, ?, NULL, NULL, 1)
|
||||||
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), is_active = 1`,
|
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), session_version = session_version + 1, is_active = 1`,
|
||||||
[name, email, passwordHash, adminRole[0].id]
|
[name, email, passwordHash, adminRole[0].id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { HttpError } from './http.js';
|
||||||
|
|
||||||
|
export function requireEditVersion(value) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 1) throw new HttpError(428, 'Falta la version de edicion');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireUpdated(row) {
|
||||||
|
if (!row) throw new HttpError(409, 'El registro cambio en el servidor. Recarga antes de guardar');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invitationEmail(url) {
|
||||||
|
const safeUrl = escapeHtml(url);
|
||||||
|
return `<p>Te agregaron como miembro de un proyecto en project.linkedcorp.net.</p>
|
||||||
|
<p>Completa tu registro para acceder (elige tu nombre y una clave):</p>
|
||||||
|
<p><a href="${safeUrl}">${safeUrl}</a></p><p>Este enlace vence en 7 dias.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verificationEmail(name, url) {
|
||||||
|
const safeUrl = escapeHtml(url);
|
||||||
|
return `<p>Hola ${escapeHtml(name)},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Este enlace vence en 24 horas.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoveryEmail(name, url) {
|
||||||
|
const safeUrl = escapeHtml(url);
|
||||||
|
return `<p>Hola ${escapeHtml(name)},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { getSmtpConfigInternal } from '../modules/settings/service.js';
|
|||||||
// Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de
|
// Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de
|
||||||
// fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando.
|
// fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando.
|
||||||
export async function sendMail({ to, subject, html }) {
|
export async function sendMail({ to, subject, html }) {
|
||||||
|
try {
|
||||||
const config = await getSmtpConfigInternal();
|
const config = await getSmtpConfigInternal();
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -21,7 +22,6 @@ export async function sendMail({ to, subject, html }) {
|
|||||||
|
|
||||||
// Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama —
|
// Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama —
|
||||||
// critico para forgot-password, que siempre debe responder generico sin importar el resultado.
|
// critico para forgot-password, que siempre debe responder generico sin importar el resultado.
|
||||||
try {
|
|
||||||
await transporter.sendMail({ from: config.from, to, subject, html });
|
await transporter.sendMail({ from: config.from, to, subject, html });
|
||||||
return { sent: true, dev: false };
|
return { sent: true, dev: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function redactUrl(url = '') {
|
||||||
|
const value = String(url);
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value, 'http://local.invalid');
|
||||||
|
for (const key of parsed.searchParams.keys()) {
|
||||||
|
if (key.toLowerCase() === 'token') parsed.searchParams.set(key, '[REDACTED]');
|
||||||
|
}
|
||||||
|
return parsed.pathname + parsed.search + parsed.hash;
|
||||||
|
} catch {
|
||||||
|
return value.replace(/([?&]token=)[^&#]*/gi, '$1[REDACTED]');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,13 +24,22 @@ Navegador (superadmin/admin/it_manager) --JWT--> /api/devices -> menú "Equipos
|
|||||||
|
|
||||||
- **Handshake:** el agente primero manda un `contact` (JSON) o `PROLOG` (XML). Se responde sin
|
- **Handshake:** el agente primero manda un `contact` (JSON) o `PROLOG` (XML). Se responde sin
|
||||||
guardar nada (`{"status":"ok","expiration":"24"}` o `<REPLY><RESPONSE>SEND</RESPONSE>…`).
|
guardar nada (`{"status":"ok","expiration":"24"}` o `<REPLY><RESPONSE>SEND</RESPONSE>…`).
|
||||||
- **Ingesta autenticada por token**, no por JWT: tabla `inventory_tokens` (solo hash sha256). El
|
- **Ingesta autenticada por token**, no por JWT: tabla `inventory_tokens` (hash sha256 para validar
|
||||||
|
y copia cifrada para recuperar el instalador). El
|
||||||
agente lo manda como contraseña Basic (`user`/`password` del `agent.cfg`), `Bearer`,
|
agente lo manda como contraseña Basic (`user`/`password` del `agent.cfg`), `Bearer`,
|
||||||
`X-Ingest-Token` o `?token=`. Sin token válido → `401`.
|
`X-Ingest-Token` o `?token=`. Sin token válido → `401`.
|
||||||
|
- Compatibilidad: `?token=` sigue aceptado para agentes instalados; la aplicación censura ese
|
||||||
|
parámetro en sus logs. El proxy inverso debe censurarlo también en sus propios access logs.
|
||||||
|
- **Pendiente de seguridad:** hoy un instalador puede usar el mismo token en varios equipos.
|
||||||
|
Vincular automáticamente token y `deviceid` rompería esos agentes ya instalados. Antes de
|
||||||
|
activarlo se necesita inventariar instalaciones y migrar a un token por equipo; luego añadir
|
||||||
|
la asociación y rechazar inventarios de otro `deviceid`.
|
||||||
- **Rutas:** `POST /api/inventory` (propia; pasa por el Custom Location `/api` de NPM sin cambios)
|
- **Rutas:** `POST /api/inventory` (propia; pasa por el Custom Location `/api` de NPM sin cambios)
|
||||||
y alias `POST /front/inventory.php` (ruta nativa de GLPI). Se montan ANTES de `express.json`
|
y alias `POST /front/inventory.php` (ruta nativa de GLPI). Se montan ANTES de `express.json`
|
||||||
porque usan cuerpo crudo.
|
porque usan cuerpo crudo.
|
||||||
- **Límites:** cuerpo máx. 15 MB (`INVENTORY_MAX_BYTES`), inflado máx. 4× (anti zip-bomb).
|
- **Límites:** cuerpo máx. 15 MB (`INVENTORY_MAX_BYTES`), inflado máx. 4× (anti zip-bomb).
|
||||||
|
- Cada IP tiene límite de 120 peticiones/minuto; máximo dos ingestiones simultáneas. El token se
|
||||||
|
valida antes de leer el cuerpo y el parseo se ejecuta en un worker con límite de tiempo.
|
||||||
- **Upsert por `deviceid`** (estable), nunca por hostname. Componentes se reemplazan completos en
|
- **Upsert por `deviceid`** (estable), nunca por hostname. Componentes se reemplazan completos en
|
||||||
una transacción; el diff (RAM, disco agregado/retirado, software instalado/desinstalado, SO,
|
una transacción; el diff (RAM, disco agregado/retirado, software instalado/desinstalado, SO,
|
||||||
hostname) queda en `device_changes`.
|
hostname) queda en `device_changes`.
|
||||||
|
|||||||
@@ -58,6 +58,10 @@
|
|||||||
- **Rate limiting** en `POST /api/auth/login`.
|
- **Rate limiting** en `POST /api/auth/login`.
|
||||||
- Sirviendo la SPA: CSP (helmet) — `script-src 'self'` (sin inline), `style-src 'self' 'unsafe-inline'`,
|
- Sirviendo la SPA: CSP (helmet) — `script-src 'self'` (sin inline), `style-src 'self' 'unsafe-inline'`,
|
||||||
`img-src 'self' data: blob:`, `object-src 'none'`.
|
`img-src 'self' data: blob:`, `object-src 'none'`.
|
||||||
|
- La SPA de producción recibe CSP desde `frontend/public/serve.json` (el servidor `serve` copia
|
||||||
|
ese archivo al build); el backend usa Helmet en `/api`. En Vite dev no se aplica el header del
|
||||||
|
servidor `serve`, para permitir el módulo de recarga de React. El logo de empresa solo admite
|
||||||
|
PNG/JPG/WEBP comprobados por firma y se entrega con `nosniff`.
|
||||||
|
|
||||||
### 1.6 Frontend / caché
|
### 1.6 Frontend / caché
|
||||||
- El JWT en `localStorage` es un trade-off aceptado; mantener CSP y sanitización.
|
- El JWT en `localStorage` es un trade-off aceptado; mantener CSP y sanitización.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Sesión, retorno al contexto y borradores
|
||||||
|
|
||||||
|
Solución compartida para toda la app (frontend). Los proyectos y comentarios también verifican
|
||||||
|
la versión en el backend (migración `029_optimistic_versions.sql`).
|
||||||
|
|
||||||
|
## Piezas
|
||||||
|
|
||||||
|
| Archivo | Qué hace |
|
||||||
|
|---|---|
|
||||||
|
| `utils/jwt.js` | Lee `exp` y `sub` del JWT (sin verificar firma; eso lo hace el backend). |
|
||||||
|
| `context/auth-context.jsx` | Vigila el vencimiento: temporizador al instante exacto, chequeo cada 30 s y al volver a la pestaña/ventana (`visibilitychange`, `focus`, `pageshow`). El navegador congela temporizadores en segundo plano, por eso el chequeo al volver es el que manda. |
|
||||||
|
| `services/api.js` | Nunca envía una petición con token vencido: corta la sesión antes. Un `401` con token también corta. |
|
||||||
|
| `utils/session.js` | Al vencer guarda `{ userId, ruta completa }` (`plc_return_ctx`, 24 h). Tras el login solo se usa si entra **el mismo usuario**; otro usuario la descarta. |
|
||||||
|
| `utils/drafts.js` + `hooks/useDraftForm.js` | Borradores por formulario (ver abajo). |
|
||||||
|
| `components/NavigationGuard.jsx` | Aviso al salir con cambios (`useBlocker`), advertencia nativa en F5/cierre, `DraftMark`, `useGuardedClose`. |
|
||||||
|
| `components/DraftNotice.jsx` | Aviso dentro del formulario: cambios recuperados / conflicto con el servidor. |
|
||||||
|
| `app/App.jsx` | Router de datos (`createBrowserRouter`, mismas rutas): requisito de `useBlocker`. |
|
||||||
|
|
||||||
|
Pestañas y vistas que viven en la URL (para volver exactamente ahí): proyecto `?tab=`, actividad
|
||||||
|
abierta `?activity=&activityTab=`, Organización `?tab=`, Equipos `?tab=&device=&deviceTab=`.
|
||||||
|
|
||||||
|
## Comportamiento
|
||||||
|
|
||||||
|
- **Vencimiento:** se cierra la sesión al instante (aunque no haya peticiones) y aparece el login con
|
||||||
|
el aviso "Tu sesión venció…". Al volver a entrar, el mismo usuario regresa a la misma ruta, pestaña
|
||||||
|
y popup, con la edición reabierta y sus cambios. Salir con "Cerrar sesión" no guarda ruta de retorno.
|
||||||
|
- **Borradores:** clave `plc_draft:v1:<usuario>:<formulario>:<entidad>` en `localStorage`, vigencia 7
|
||||||
|
días. Solo se guardan los campos permitidos de cada formulario (`draftAllowedFields`); el texto
|
||||||
|
libre de notas, comentarios, descripciones y observaciones no se conserva. Sobreviven a cambiar
|
||||||
|
de pestaña/menú, F5 y vencimiento. Se borran al guardar, descartar o cerrar sesión
|
||||||
|
voluntariamente. Solo se restauran si el registro sigue en lo que el backend
|
||||||
|
devuelve para ese usuario (si se borró o perdió acceso, no se abre nada).
|
||||||
|
- **Secretos:** nunca se guardan campos cuyo nombre parezca contraseña/token/PIN, archivos ni
|
||||||
|
campos fuera de la lista permitida. Al leer borradores antiguos se eliminan los campos excluidos.
|
||||||
|
- **Marcas:** campo modificado en rojo con el texto "· modificado"; pestaña, sección plegable,
|
||||||
|
botón "Nuevo …", tarjeta (Notas) y menú lateral con punto rojo + texto para lector de pantalla.
|
||||||
|
- **Salir con cambios** (menú, pestaña, cerrar popup, plegar sección): diálogo *Seguir editando* /
|
||||||
|
*Dejar pendiente* / *Descartar cambios*. Con "Dejar pendiente", al volver se reabre la edición con
|
||||||
|
los valores recuperados. F5 o cerrar la pestaña: advertencia nativa del navegador.
|
||||||
|
- **Integridad:** los borradores no se guardan automáticamente en el backend; Guardar envía los
|
||||||
|
campos del formulario con la versión leída. Si el registro cambió, se conserva el borrador y se
|
||||||
|
pide recargar para revisar la versión actual. En Info de proyecto/actividad, Equipos, SMTP y
|
||||||
|
Empresa además se consulta el servidor justo antes de guardar.
|
||||||
|
|
||||||
|
## Formularios sin recuperación (a propósito)
|
||||||
|
|
||||||
|
| Formulario | Motivo |
|
||||||
|
|---|---|
|
||||||
|
| Contraseñas (todo el módulo, incluido PIN y grupos) | Los datos están protegidos por PIN y cifrados; guardarlos en el navegador saltaría esa protección. |
|
||||||
|
| Cambiar contraseña (menú del usuario) | Solo contiene secretos. |
|
||||||
|
| Login, registro, olvidé/restablecer clave, aceptar invitación, restablecer PIN | Pantallas públicas: no hay usuario autenticado para aislar el borrador y contienen claves/tokens. |
|
||||||
|
| Documentos (subir archivo), logo y nombre de Empresa | Archivos y texto libre no se guardan en el navegador. |
|
||||||
|
| Equipos → Agentes (token de instalación) | Token secreto; generar uno es una acción de un paso. |
|
||||||
|
| Mover actividad, agregar dependencia, compartir nota, compartir Equipos | Acciones de una sola elección, sin captura de datos: se repiten en un clic. |
|
||||||
|
| Clave y texto libre en "Nuevo usuario"; clave SMTP | Solo se recuperan rol/departamento; las claves y el texto libre se vuelven a escribir. |
|
||||||
|
| Apariencia, filtros, selector de tarjetas del Panel | Se aplican al instante; no queda nada pendiente. |
|
||||||
|
|
||||||
|
## Límites conocidos
|
||||||
|
|
||||||
|
- Los borradores son de **este navegador**: no se sincronizan entre equipos.
|
||||||
|
- En un equipo compartido, los borradores de un usuario quedan en el navegador hasta guardarlos,
|
||||||
|
descartarlos, cerrar sesión voluntariamente o que venzan (7 días). Tras vencimiento de sesión
|
||||||
|
se conservan para permitir el retorno al contexto.
|
||||||
|
- Todos los formularios de edición de entidades, empresa y SMTP envían `editVersion`; el backend
|
||||||
|
rechaza con `409` una versión obsoleta. El borrador se conserva y el aviso pide revisar la
|
||||||
|
versión actual. Miembros no edita una entidad mediante formulario: sus acciones aplican cambios
|
||||||
|
puntuales de acceso.
|
||||||
|
- Los popups son modales: con uno abierto no se puede usar el menú lateral; se sale cerrándolo (pasa
|
||||||
|
por el mismo aviso) o con atrás del navegador.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "2.9.0",
|
"version": "3.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "2.9.0",
|
"version": "3.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.4.16",
|
"dompurify": "^3.4.16",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.19.0",
|
"version": "3.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 994 KiB |
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"headers": [
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "**",
|
||||||
|
"headers": [{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" }]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "**/*.@(html|webmanifest)",
|
"source": "**/*.@(html|webmanifest)",
|
||||||
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
|
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
|
||||||
|
|||||||
+15
-11
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { createBrowserRouter, createRoutesFromElements, RouterProvider, Route } from 'react-router-dom';
|
||||||
import { AuthProvider } from '../context/auth-context.jsx';
|
import { AuthProvider } from '../context/auth-context.jsx';
|
||||||
import { UiProvider } from '../context/ui-context.jsx';
|
import { UiProvider } from '../context/ui-context.jsx';
|
||||||
import { BrandingProvider } from '../context/branding-context.jsx';
|
import { BrandingProvider } from '../context/branding-context.jsx';
|
||||||
@@ -24,14 +24,10 @@ import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx';
|
|||||||
import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
|
import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
|
||||||
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
|
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
|
||||||
|
|
||||||
export default function App() {
|
// Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una
|
||||||
return (
|
// seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes.
|
||||||
<ErrorBoundary>
|
const router = createBrowserRouter(createRoutesFromElements(
|
||||||
<BrandingProvider>
|
<>
|
||||||
<UiProvider>
|
|
||||||
<AuthProvider>
|
|
||||||
<BrowserRouter>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/registro" element={<RegisterPage />} />
|
<Route path="/registro" element={<RegisterPage />} />
|
||||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||||
@@ -50,8 +46,16 @@ export default function App() {
|
|||||||
<Route path="organizacion" element={<OrganizacionPage />} />
|
<Route path="organizacion" element={<OrganizacionPage />} />
|
||||||
<Route path="configuracion" element={<ConfiguracionPage />} />
|
<Route path="configuracion" element={<ConfiguracionPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</>
|
||||||
</BrowserRouter>
|
));
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BrandingProvider>
|
||||||
|
<UiProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
<ContentDialogHost />
|
<ContentDialogHost />
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Outlet, useNavigate } from 'react-router-dom';
|
import { Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { Menu, Sun, Moon, Bell, LogOut, User, Globe, KeyRound, Save } from 'lucide-react';
|
import { Menu, Sun, Moon, Bell, LogOut, User, Globe, KeyRound, Save } from 'lucide-react';
|
||||||
import { NavigationView } from '../components/NavigationView.jsx';
|
import { NavigationView } from '../components/NavigationView.jsx';
|
||||||
|
import { NavigationGuard } from '../components/NavigationGuard.jsx';
|
||||||
import { Flyout, FlyoutItem, FlyoutSeparator } from '../components/Flyout.jsx';
|
import { Flyout, FlyoutItem, FlyoutSeparator } from '../components/Flyout.jsx';
|
||||||
import { DetailPopup } from '../components/DetailPopup.jsx';
|
import { DetailPopup } from '../components/DetailPopup.jsx';
|
||||||
import { Field } from '../components/Field.jsx';
|
import { Field } from '../components/Field.jsx';
|
||||||
@@ -167,6 +168,7 @@ export default function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="content-area">
|
<div className="content-area">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<NavigationGuard />
|
||||||
</div>
|
</div>
|
||||||
<div className="footbar">
|
<div className="footbar">
|
||||||
<span>© {new Date().getFullYear()} {t('appName')}</span>
|
<span>© {new Date().getFullYear()} {t('appName')}</span>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/auth-context.jsx';
|
import { useAuth } from '../context/auth-context.jsx';
|
||||||
|
|
||||||
export function ProtectedRoute({ children }) {
|
export function ProtectedRoute({ children }) {
|
||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
if (!ready) return null;
|
if (!ready) return null;
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
// `from`: para volver a la ruta que se intentaba abrir despues de iniciar sesion.
|
||||||
|
if (!user) return <Navigate to="/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { ChevronDown, Search } from 'lucide-react';
|
import { ChevronDown, Search } from 'lucide-react';
|
||||||
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
|
||||||
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
|
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
|
||||||
// El panel desplegable se porta a document.body y se posiciona con `position: fixed` calculado
|
// El panel desplegable se porta a document.body y se posiciona con `position: fixed` calculado
|
||||||
// desde el trigger (igual tecnica que Flyout) — evita que un ancestro con overflow:hidden/auto
|
// desde el trigger (igual tecnica que Flyout) — evita que un ancestro con overflow:hidden/auto
|
||||||
// (Expander, DetailPopup, cualquier tarjeta con scroll propio) recorte la lista desplegada.
|
// (Expander, DetailPopup, cualquier tarjeta con scroll propio) recorte la lista desplegada.
|
||||||
// options: [{ value, label }]
|
// options: [{ value, label }]
|
||||||
export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) {
|
export function ComboBox({ id, options, value, onChange, placeholder = '', searchThreshold = 10 }) {
|
||||||
|
const { t } = useUi();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [pos, setPos] = useState(null); // { top, left, width, anchorTop, anchorBottom, ready }
|
const [pos, setPos] = useState(null); // { top, left, width, anchorTop, anchorBottom, ready }
|
||||||
const triggerRef = useRef(null);
|
const triggerRef = useRef(null);
|
||||||
const panelRef = useRef(null);
|
const panelRef = useRef(null);
|
||||||
@@ -21,6 +24,7 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
|
|||||||
anchorTop: rect.top, anchorBottom: rect.bottom, ready: false,
|
anchorTop: rect.top, anchorBottom: rect.bottom, ready: false,
|
||||||
});
|
});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
setActiveIndex(Math.max(0, options.findIndex((o) => String(o.value) === String(value))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fase 2 (igual tecnica que Flyout): con el panel ya en el DOM (oculto) se mide su alto real y,
|
// Fase 2 (igual tecnica que Flyout): con el panel ya en el DOM (oculto) se mide su alto real y,
|
||||||
@@ -77,21 +81,43 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
|
|||||||
const filtered = query
|
const filtered = query
|
||||||
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
: options;
|
: options;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !pos?.ready) return;
|
||||||
|
const target = panelRef.current?.querySelector('.combobox-search input') || panelRef.current?.querySelector('[role="option"]');
|
||||||
|
target?.focus();
|
||||||
|
}, [open, pos?.ready]);
|
||||||
|
function choose(option) { onChange(option.value); setOpen(false); setQuery(''); triggerRef.current?.focus(); }
|
||||||
|
function handleKey(event) {
|
||||||
|
if (event.key === 'Escape') { setOpen(false); triggerRef.current?.focus(); return; }
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!open) { openPanel(); return; }
|
||||||
|
const next = (activeIndex + (event.key === 'ArrowDown' ? 1 : -1) + filtered.length) % (filtered.length || 1);
|
||||||
|
setActiveIndex(next);
|
||||||
|
requestAnimationFrame(() => panelRef.current?.querySelectorAll('[role="option"]')[next]?.focus());
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && open && filtered[activeIndex]) { event.preventDefault(); choose(filtered[activeIndex]); }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="combobox">
|
<div className="combobox">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
|
id={id}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={placeholder || selected?.label || t('common_select')}
|
||||||
className="input"
|
className="input"
|
||||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
|
||||||
onClick={() => (open ? setOpen(false) : openPanel())}
|
onClick={() => (open ? setOpen(false) : openPanel())}
|
||||||
|
onKeyDown={handleKey}
|
||||||
>
|
>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
{open && pos && createPortal(
|
{open && pos && createPortal(
|
||||||
<div ref={panelRef} className="combobox-panel" style={{ top: pos.top, left: pos.left, width: pos.width, visibility: pos.ready ? 'visible' : 'hidden' }}>
|
<div ref={panelRef} className="combobox-panel" role="listbox" aria-label={placeholder || t('common_select')} onKeyDown={handleKey} style={{ top: pos.top, left: pos.left, width: pos.width, visibility: pos.ready ? 'visible' : 'hidden' }}>
|
||||||
{options.length >= searchThreshold && (
|
{options.length >= searchThreshold && (
|
||||||
<div className="combobox-search" style={{ position: 'relative' }}>
|
<div className="combobox-search" style={{ position: 'relative' }}>
|
||||||
<Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} />
|
<Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} />
|
||||||
@@ -100,20 +126,25 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
|
|||||||
className="input"
|
className="input"
|
||||||
style={{ paddingLeft: 26 }}
|
style={{ paddingLeft: 26 }}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => { setQuery(e.target.value); setActiveIndex(0); }}
|
||||||
placeholder="Buscar..."
|
placeholder={t('common_search')}
|
||||||
|
aria-label={t('common_search')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filtered.length === 0 && <div className="combobox-empty">Sin resultados</div>}
|
{filtered.length === 0 && <div className="combobox-empty">{t('common_search_no_results')}</div>}
|
||||||
{filtered.map((o) => (
|
{filtered.map((o, i) => (
|
||||||
<div
|
<button type="button"
|
||||||
key={o.value}
|
key={o.value}
|
||||||
|
role="option"
|
||||||
|
aria-selected={String(o.value) === String(value)}
|
||||||
|
tabIndex={i === activeIndex ? 0 : -1}
|
||||||
className={`combobox-option ${String(o.value) === String(value) ? 'active' : ''}`}
|
className={`combobox-option ${String(o.value) === String(value) ? 'active' : ''}`}
|
||||||
onClick={() => { onChange(o.value); setOpen(false); setQuery(''); }}
|
onClick={() => choose(o)}
|
||||||
|
onFocus={() => setActiveIndex(i)}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Send, Pencil, Trash2, Save, X } from 'lucide-react';
|
import { Send, Pencil, Trash2, Save, X } from 'lucide-react';
|
||||||
import { useUi } from '../context/ui-context.jsx';
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
import { useAuth } from '../context/auth-context.jsx';
|
import { useAuth } from '../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../hooks/useAsync.js';
|
import { useAsync, useAction } from '../hooks/useAsync.js';
|
||||||
import { listComments, createComment, updateComment, deleteComment } from '../services/comments.js';
|
import { listComments, createComment, updateComment, deleteComment } from '../services/comments.js';
|
||||||
import { Button } from './Button.jsx';
|
import { Button } from './Button.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts } from '../hooks/useDraftForm.js';
|
||||||
|
|
||||||
const MANAGE_ROLES = ['superadmin', 'admin'];
|
const MANAGE_ROLES = ['superadmin', 'admin'];
|
||||||
|
|
||||||
@@ -13,15 +14,35 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
|
|||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: comments, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
|
const { data: comments, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
const [editingText, setEditingText] = useState('');
|
// Borradores: comentario nuevo (por entidad) y edicion de un comentario existente.
|
||||||
|
const scope = `${entityType}-${entityId}`;
|
||||||
|
const newDraft = useDraftForm({ formId: 'comment-new', entityId: scope, initial: { message: '' }, label: t('comment_placeholder') });
|
||||||
|
const message = newDraft.values.message;
|
||||||
|
const setMessage = (v) => newDraft.setField('message', v);
|
||||||
|
const editingComment = (comments || []).find((c) => c.id === editingId);
|
||||||
|
const editDraft = useDraftForm({
|
||||||
|
formId: 'comment-edit', entityId: editingId, initial: { message: editingComment?.message || '', editVersion: editingComment?.edit_version },
|
||||||
|
label: `${t('common_edit')}: ${(editingComment?.message || '').slice(0, 40)}`, enabled: !!editingComment, onDiscard: () => setEditingId(null),
|
||||||
|
});
|
||||||
|
const editingText = editDraft.values.message;
|
||||||
|
const setEditingText = (v) => editDraft.setField('message', v);
|
||||||
|
// Al volver con un borrador de edicion de un comentario de esta entidad, se reabre la edicion.
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!comments || editingId) return;
|
||||||
|
const d = drafts.find((x) => x.formId === 'comment-edit' && comments.some((c) => String(c.id) === String(x.entityId)));
|
||||||
|
if (d) {
|
||||||
|
setEditingId(Number(d.entityId));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
if (!message.trim()) return;
|
if (!message.trim()) return;
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await createComment(entityType, entityId, message.trim());
|
await createComment(entityType, entityId, message.trim());
|
||||||
setMessage('');
|
newDraft.clear();
|
||||||
await reload();
|
await reload();
|
||||||
onChanged?.(); // refresca el contador de la pestaña "Comentarios" en el padre
|
onChanged?.(); // refresca el contador de la pestaña "Comentarios" en el padre
|
||||||
});
|
});
|
||||||
@@ -33,21 +54,21 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
|
|||||||
|
|
||||||
function startEdit(c) {
|
function startEdit(c) {
|
||||||
setEditingId(c.id);
|
setEditingId(c.id);
|
||||||
setEditingText(c.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
|
editDraft.discard();
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setEditingText('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit(id) {
|
async function saveEdit(id) {
|
||||||
if (!editingText.trim()) return;
|
if (!editingText.trim()) return;
|
||||||
await runAction(async () => {
|
await editDraft.guardSave(() => runAction(async () => {
|
||||||
await updateComment(id, editingText.trim());
|
await updateComment(id, editingText.trim(), editDraft.values.editVersion);
|
||||||
cancelEdit();
|
editDraft.clear();
|
||||||
|
setEditingId(null);
|
||||||
await reload();
|
await reload();
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(c) {
|
function confirmDelete(c) {
|
||||||
@@ -90,7 +111,8 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
|
|||||||
{editingId === c.id ? (
|
{editingId === c.id ? (
|
||||||
<div style={{ marginTop: 6 }}>
|
<div style={{ marginTop: 6 }}>
|
||||||
<input
|
<input
|
||||||
className="input" value={editingText} autoFocus
|
className={`input ${editDraft.isDirty ? 'input-dirty' : ''}`} value={editingText} autoFocus
|
||||||
|
aria-label={editDraft.isDirty ? `${t('common_edit')} (${t('draft_field_modified')})` : t('common_edit')}
|
||||||
onChange={(e) => setEditingText(e.target.value)}
|
onChange={(e) => setEditingText(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(c.id); if (e.key === 'Escape') cancelEdit(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(c.id); if (e.key === 'Escape') cancelEdit(); }}
|
||||||
/>
|
/>
|
||||||
@@ -105,7 +127,11 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
<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()} />
|
<input
|
||||||
|
className={`input ${newDraft.isDirty ? 'input-dirty' : ''}`} value={message} placeholder={t('comment_placeholder')}
|
||||||
|
aria-label={newDraft.isDirty ? `${t('comment_placeholder')} (${t('draft_field_modified')})` : 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>
|
<Button variant="accent" icon={Send} onClick={send}>{t('comment_send')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useUi } from '../context/ui-context.jsx';
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
import { Button } from './Button.jsx';
|
import { Button } from './Button.jsx';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { useModalFocus } from '../hooks/useModalFocus.js';
|
||||||
|
|
||||||
const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', error: 'danger' };
|
const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', error: 'danger' };
|
||||||
|
|
||||||
@@ -8,6 +10,8 @@ const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', e
|
|||||||
// openDialog({ tone, title, message, actions: [{ label, variant, keepOpen, onClick }] })
|
// openDialog({ tone, title, message, actions: [{ label, variant, keepOpen, onClick }] })
|
||||||
export function ContentDialogHost() {
|
export function ContentDialogHost() {
|
||||||
const { dialog, closeDialog, t } = useUi();
|
const { dialog, closeDialog, t } = useUi();
|
||||||
|
const titleId = useId();
|
||||||
|
const modalRef = useModalFocus(!!dialog);
|
||||||
if (!dialog) return null;
|
if (!dialog) return null;
|
||||||
|
|
||||||
const tone = dialog.tone || 'info';
|
const tone = dialog.tone || 'info';
|
||||||
@@ -23,10 +27,10 @@ export function ContentDialogHost() {
|
|||||||
// encima de cualquier DetailPopup abierto, sin depender del punto del arbol donde se monta.
|
// encima de cualquier DetailPopup abierto, sin depender del punto del arbol donde se monta.
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="dialog-overlay">
|
<div className="dialog-overlay">
|
||||||
<div className="dialog-surface">
|
<div className="dialog-surface" role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} ref={modalRef}>
|
||||||
<div className="dialog-stripe" style={{ background: `rgb(var(--${STRIPE_COLOR[tone]}))` }} />
|
<div className="dialog-stripe" style={{ background: `rgb(var(--${STRIPE_COLOR[tone]}))` }} />
|
||||||
<div className="dialog-body">
|
<div className="dialog-body">
|
||||||
<div className="dialog-title">{dialog.title}</div>
|
<div className="dialog-title" id={titleId}>{dialog.title}</div>
|
||||||
<div className="dialog-message">{dialog.message}</div>
|
<div className="dialog-message">{dialog.message}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dialog-actions">
|
<div className="dialog-actions">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { useModalFocus } from '../hooks/useModalFocus.js';
|
||||||
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
|
||||||
// Popup grande para ver/editar el detalle de un elemento de un listado (reemplaza el patron
|
// Popup grande para ver/editar el detalle de un elemento de un listado (reemplaza el patron
|
||||||
// "panel debajo del listado"). Reglas duras:
|
// "panel debajo del listado"). Reglas duras:
|
||||||
@@ -9,13 +12,16 @@ import { X } from 'lucide-react';
|
|||||||
// (ej. Info/Comentarios/Documentos) nunca debe cambiar su tamaño — solo el contenido interno
|
// (ej. Info/Comentarios/Documentos) nunca debe cambiar su tamaño — solo el contenido interno
|
||||||
// hace scroll. `height` es fijo por instancia; ajustar via prop segun cuanto contenido espera.
|
// hace scroll. `height` es fijo por instancia; ajustar via prop segun cuanto contenido espera.
|
||||||
export function DetailPopup({ open, title, onClose, children, width = 720, height = 640 }) {
|
export function DetailPopup({ open, title, onClose, children, width = 720, height = 640 }) {
|
||||||
|
const titleId = useId();
|
||||||
|
const { t } = useUi();
|
||||||
|
const modalRef = useModalFocus(open, onClose);
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="detail-popup-overlay">
|
<div className="detail-popup-overlay">
|
||||||
<div className="detail-popup" style={{ maxWidth: width, height }}>
|
<div className="detail-popup" style={{ maxWidth: width, height }} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} ref={modalRef}>
|
||||||
<div className="detail-popup-header">
|
<div className="detail-popup-header">
|
||||||
<div className="detail-popup-title">{title}</div>
|
<div className="detail-popup-title" id={titleId}>{title}</div>
|
||||||
<button className="icon-btn" onClick={onClose}><X /></button>
|
<button className="icon-btn" aria-label={t('common_close')} onClick={onClose}><X /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-popup-body">{children}</div>
|
<div className="detail-popup-body">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
import { InfoBar } from './InfoBar.jsx';
|
||||||
|
import { Button } from './Button.jsx';
|
||||||
|
|
||||||
|
// Aviso dentro de un formulario con borrador (ver hooks/useDraftForm.js):
|
||||||
|
// - conflicto: el registro cambio en el servidor desde que se empezo a editar.
|
||||||
|
// - recuperado: se restauraron cambios sin guardar de una sesion/visita anterior.
|
||||||
|
export function DraftNotice({ draft }) {
|
||||||
|
const { t } = useUi();
|
||||||
|
if (!draft) return null;
|
||||||
|
if (draft.conflict) {
|
||||||
|
return (
|
||||||
|
<InfoBar tone="warning" dismissible={false}>
|
||||||
|
<div>{t(draft.values?.editVersion ? 'draft_conflict_reload_msg' : 'draft_conflict_msg')}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
|
||||||
|
{draft.values?.editVersion ? (
|
||||||
|
<Button variant="danger" onClick={() => { draft.discard(); window.location.reload(); }}>{t('draft_conflict_reload')}</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="standard" onClick={draft.keepMine}>{t('draft_keep_mine')}</Button>
|
||||||
|
<Button variant="danger" onClick={draft.discard}>{t('draft_use_server')}</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</InfoBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (draft.restored && draft.isDirty) {
|
||||||
|
return (
|
||||||
|
<InfoBar tone="info" dismissible={false}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ flex: 1 }}>{t('draft_restored_msg')}</span>
|
||||||
|
<Button variant="standard" onClick={draft.discard}>{t('draft_discard')}</Button>
|
||||||
|
</div>
|
||||||
|
</InfoBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { useUserDrafts } from '../hooks/useDraftForm.js';
|
||||||
|
import { getActiveEditors } from '../utils/drafts.js';
|
||||||
|
import { DraftMark, useLeaveConfirm } from './NavigationGuard.jsx';
|
||||||
|
|
||||||
|
// draftForms: prefijos de formId de los formularios que viven dentro de esta seccion. Con eso la
|
||||||
|
// seccion se marca si tiene borradores, se abre sola al volver, y al plegarla con cambios en
|
||||||
|
// edicion pide confirmacion (plegar desmonta el contenido).
|
||||||
|
export function Expander({ icon: Icon, title, description, defaultOpen = false, draftForms, children }) {
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
const matches = (formId) => (draftForms || []).some((p) => formId.startsWith(p));
|
||||||
|
const hasDraft = drafts.some((d) => matches(d.formId));
|
||||||
|
const [open, setOpen] = useState(defaultOpen || hasDraft);
|
||||||
|
const confirmLeave = useLeaveConfirm();
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
if (!open) { setOpen(true); return; }
|
||||||
|
const editors = getActiveEditors().filter((e) => (draftForms || []).some((p) => e.key.includes(`:${p}`)));
|
||||||
|
if (editors.length && (await confirmLeave(editors)) === 'stay') return;
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
export function Expander({ icon: Icon, title, description, defaultOpen = false, children }) {
|
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
|
||||||
return (
|
return (
|
||||||
<div className={`expander ${open ? 'open' : ''}`}>
|
<div className={`expander ${open ? 'open' : ''}`}>
|
||||||
<div className="expander-header" onClick={() => setOpen((v) => !v)}>
|
<button type="button" className="expander-header" aria-expanded={open} onClick={toggle}>
|
||||||
{Icon && <Icon size={18} />}
|
{Icon && <Icon size={18} />}
|
||||||
<div>
|
<div>
|
||||||
<div className="expander-title">{title}</div>
|
<div className="expander-title">{title}{hasDraft && <DraftMark />}</div>
|
||||||
{description && <div className="expander-desc">{description}</div>}
|
{description && <div className="expander-desc">{description}</div>}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="chevron" size={16} />
|
<ChevronRight className="chevron" size={16} />
|
||||||
</div>
|
</button>
|
||||||
{open && <div className="expander-body">{children}</div>}
|
{open && <div className="expander-body">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
export function Field({ label, children, hint }) {
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
import { cloneElement, isValidElement, useId } from 'react';
|
||||||
|
import { ComboBox } from './ComboBox.jsx';
|
||||||
|
|
||||||
|
// dirty: el valor difiere de lo guardado (borrador pendiente). Se marca en rojo y con texto para
|
||||||
|
// lectores de pantalla, no solo con color.
|
||||||
|
export function Field({ label, children, hint, dirty }) {
|
||||||
|
const { t } = useUi();
|
||||||
|
const controlId = useId();
|
||||||
|
const control = isValidElement(children) && (['input', 'textarea', 'select'].includes(children.type) || children.type === ComboBox);
|
||||||
return (
|
return (
|
||||||
<div className="field">
|
<div className={`field ${dirty ? 'field-dirty' : ''}`}>
|
||||||
{label && <label className="field-label">{label}</label>}
|
{label && (
|
||||||
{children}
|
control ? <label className="field-label" htmlFor={controlId}>
|
||||||
|
{label}
|
||||||
|
{dirty && <span className="field-dirty-tag"> · {t('draft_field_modified')}</span>}
|
||||||
|
</label> : <div className="field-label">{label}{dirty && <span className="field-dirty-tag"> · {t('draft_field_modified')}</span>}</div>
|
||||||
|
)}
|
||||||
|
{control ? cloneElement(children, { id: children.props.id || controlId }) : children}
|
||||||
{hint && <span className="text-muted" style={{ fontSize: '.72rem' }}>{hint}</span>}
|
{hint && <span className="text-muted" style={{ fontSize: '.72rem' }}>{hint}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
|||||||
<tr>
|
<tr>
|
||||||
{dragEnabled && <th style={{ width: 28 }} />}
|
{dragEnabled && <th style={{ width: 28 }} />}
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th key={col.key} onClick={() => col.sortable !== false && toggleSort(col.key)}>
|
<th key={col.key} aria-sort={col.sortable !== false && sort.key === col.key ? (sort.dir === 1 ? 'ascending' : 'descending') : undefined}>
|
||||||
{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}
|
{col.sortable === false ? col.label : <button type="button" className="table-sort-button" onClick={() => toggleSort(col.key)}>{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}</button>}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{rowActions && <th style={{ width: 40 }} />}
|
{rowActions && <th style={{ width: 40 }} />}
|
||||||
@@ -57,6 +57,8 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
|||||||
<tr
|
<tr
|
||||||
key={getRowKey(row)}
|
key={getRowKey(row)}
|
||||||
onClick={() => onRowClick?.(row)}
|
onClick={() => onRowClick?.(row)}
|
||||||
|
tabIndex={onRowClick ? 0 : undefined}
|
||||||
|
onKeyDown={(event) => { if (onRowClick && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onRowClick(row); } }}
|
||||||
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
|
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
|
||||||
{...(dragEnabled ? getRowProps(row) : {})}
|
{...(dragEnabled ? getRowProps(row) : {})}
|
||||||
>
|
>
|
||||||
@@ -71,7 +73,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
|||||||
{rowActions && (
|
{rowActions && (
|
||||||
<td onClick={(e) => e.stopPropagation()}>
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
<Flyout trigger={({ onClick }) => (
|
<Flyout trigger={({ onClick }) => (
|
||||||
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical /></button>
|
<button className="icon-btn row-menu-btn" aria-label={t('common_actions')} onClick={onClick}><MoreVertical /></button>
|
||||||
)}>
|
)}>
|
||||||
{rowActions(row).map((action, i) => (
|
{rowActions(row).map((action, i) => (
|
||||||
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
|
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useBlocker } from 'react-router-dom';
|
||||||
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
import { getActiveEditors, isGuardBypassed } from '../utils/drafts.js';
|
||||||
|
|
||||||
|
const urlChanged = (a, b) => a.pathname !== b.pathname || a.search !== b.search;
|
||||||
|
|
||||||
|
// Aviso de tres opciones cuando se sale de una seccion con cambios sin guardar. Devuelve
|
||||||
|
// 'stay' | 'keep' | 'discard'. Reutilizable para navegacion (menu/pestañas) y para cerrar popups.
|
||||||
|
export function useLeaveConfirm() {
|
||||||
|
const { t, openDialog } = useUi();
|
||||||
|
return useCallback((editors) => new Promise((resolve) => {
|
||||||
|
const names = editors.map((e) => e.label).filter(Boolean);
|
||||||
|
openDialog({
|
||||||
|
tone: 'warning',
|
||||||
|
title: t('draft_leave_title'),
|
||||||
|
message: (
|
||||||
|
<div>
|
||||||
|
<div>{t('draft_leave_msg')}</div>
|
||||||
|
{names.length > 0 && (
|
||||||
|
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
||||||
|
{names.map((n) => <li key={n}>{n}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="text-muted" style={{ marginTop: 8, fontSize: '.78rem' }}>{t('draft_leave_hint')}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
{ label: t('draft_discard'), variant: 'danger', onClick: () => { editors.forEach((e) => e.discard?.()); resolve('discard'); } },
|
||||||
|
{ label: t('draft_keep_pending'), variant: 'standard', onClick: () => resolve('keep') },
|
||||||
|
{ label: t('draft_keep_editing'), variant: 'accent', onClick: () => resolve('stay') },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}), [openDialog, t]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cierre de un popup/editor con cambios: mismo aviso de tres opciones que al cambiar de menu.
|
||||||
|
// Uso: const guardedClose = useGuardedClose(); ... onClose={() => guardedClose(draft, () => setOpen(false))}
|
||||||
|
export function useGuardedClose() {
|
||||||
|
const confirmLeave = useLeaveConfirm();
|
||||||
|
return useCallback(async (draft, close) => {
|
||||||
|
if (!draft?.isDirty) { draft?.discard?.(); close(); return; }
|
||||||
|
const choice = await confirmLeave([{ label: draft.label, discard: draft.discard }]);
|
||||||
|
if (choice !== 'stay') close();
|
||||||
|
}, [confirmLeave]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Montado una vez dentro del area autenticada (AppShell). Intercepta cambios de ruta o de
|
||||||
|
// parametros (?tab=, ?activity=, menu lateral, atras/adelante) mientras haya un formulario con
|
||||||
|
// cambios, y avisa con la advertencia nativa del navegador ante F5 o cierre de la pestaña.
|
||||||
|
export function NavigationGuard() {
|
||||||
|
const confirmLeave = useLeaveConfirm();
|
||||||
|
const blocker = useBlocker(({ currentLocation, nextLocation }) => (
|
||||||
|
!isGuardBypassed() && urlChanged(currentLocation, nextLocation) && getActiveEditors().length > 0
|
||||||
|
));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (blocker.state !== 'blocked') return;
|
||||||
|
let active = true;
|
||||||
|
confirmLeave(getActiveEditors()).then((choice) => {
|
||||||
|
if (!active) return;
|
||||||
|
if (choice === 'stay') blocker.reset();
|
||||||
|
else blocker.proceed();
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [blocker.state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onBeforeUnload(e) {
|
||||||
|
if (isGuardBypassed() || getActiveEditors().length === 0) return;
|
||||||
|
// Los cambios ya quedan como borrador; la advertencia nativa evita una salida por error.
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marca accesible de "cambios sin guardar" para pestañas, menus, tarjetas y titulos: punto rojo
|
||||||
|
// + texto para lectores de pantalla (no depende solo del color).
|
||||||
|
export function DraftMark({ title }) {
|
||||||
|
const { t } = useUi();
|
||||||
|
const text = title || t('draft_pending_mark');
|
||||||
|
return (
|
||||||
|
<span className="draft-mark" title={text}>
|
||||||
|
<span aria-hidden="true" className="draft-dot" />
|
||||||
|
<span className="sr-only">{text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useUserDrafts } from '../hooks/useDraftForm.js';
|
||||||
|
import { DraftMark } from './NavigationGuard.jsx';
|
||||||
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
|
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useUi } from '../context/ui-context.jsx';
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
@@ -34,6 +36,10 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
|
|||||||
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices));
|
const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices));
|
||||||
const brandName = branding.name || t('appName');
|
const brandName = branding.name || t('appName');
|
||||||
|
// Menus con borradores pendientes (el borrador guarda la ruta donde se estaba editando).
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
const hasDraftUnder = (to) => drafts.some((d) => typeof d.path === 'string'
|
||||||
|
&& (to === '/' ? d.path === '/' || d.path.startsWith('/?') : d.path === to || d.path.startsWith(`${to}/`) || d.path.startsWith(`${to}?`)));
|
||||||
return (
|
return (
|
||||||
<nav className={`nav-view ${mode} ${mobileOpen ? 'mobile-open' : ''}`}>
|
<nav className={`nav-view ${mode} ${mobileOpen ? 'mobile-open' : ''}`}>
|
||||||
{branding.logoUrl && (
|
{branding.logoUrl && (
|
||||||
@@ -50,6 +56,7 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
|
|||||||
<NavLink key={item.to} to={item.to} end={item.end} onClick={onNavigate} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
<NavLink key={item.to} to={item.to} end={item.end} onClick={onNavigate} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
{!compact && <span>{t(item.labelKey)}</span>}
|
{!compact && <span>{t(item.labelKey)}</span>}
|
||||||
|
{hasDraftUnder(item.to) && <DraftMark />}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,47 @@
|
|||||||
|
import { DraftMark } from './NavigationGuard.jsx';
|
||||||
|
import { useEffect, useId, useRef } from 'react';
|
||||||
|
|
||||||
export function Tabs({ tabs, active, onChange }) {
|
export function Tabs({ tabs, active, onChange }) {
|
||||||
|
const id = useId();
|
||||||
|
const listRef = useRef(null);
|
||||||
|
const tabRefs = useRef({});
|
||||||
|
useEffect(() => {
|
||||||
|
const panel = listRef.current?.nextElementSibling;
|
||||||
|
if (!panel) return;
|
||||||
|
panel.id = `${id}-panel`;
|
||||||
|
panel.setAttribute('role', 'tabpanel');
|
||||||
|
panel.setAttribute('aria-labelledby', `${id}-${active}`);
|
||||||
|
}, [active, id]);
|
||||||
|
function onKeyDown(event, index) {
|
||||||
|
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||||
|
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : delta ? (index + delta + tabs.length) % tabs.length : null;
|
||||||
|
if (next === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onChange(tabs[next].key);
|
||||||
|
tabRefs.current[tabs[next].key]?.focus();
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="tabs">
|
<div className="tabs" role="tablist" ref={listRef}>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab, index) => (
|
||||||
<button key={tab.key} className={`tab-item ${active === tab.key ? 'active' : ''}`} onClick={() => onChange(tab.key)}>
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
id={`${id}-${tab.key}`}
|
||||||
|
ref={(node) => { tabRefs.current[tab.key] = node; }}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === tab.key}
|
||||||
|
aria-controls={`${id}-panel`}
|
||||||
|
tabIndex={active === tab.key ? 0 : -1}
|
||||||
|
onKeyDown={(event) => onKeyDown(event, index)}
|
||||||
|
className={`tab-item ${active === tab.key ? 'active' : ''} ${tab.dirty ? 'tab-dirty' : ''}`}
|
||||||
|
onClick={() => onChange(tab.key)}
|
||||||
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{/* count=0 SI se muestra (badge "0") -- solo se omite si la pestaña ni siquiera manda
|
{/* count=0 SI se muestra (badge "0") -- solo se omite si la pestaña ni siquiera manda
|
||||||
la prop (undefined), para pestañas que no tienen numero que mostrar (Info/Gantt). */}
|
la prop (undefined), para pestañas que no tienen numero que mostrar (Info/Gantt). */}
|
||||||
{tab.count !== undefined && <span className="tab-badge">{tab.count}</span>}
|
{tab.count !== undefined && <span className="tab-badge">{tab.count}</span>}
|
||||||
|
{/* Pestaña con cambios sin guardar (borrador pendiente). */}
|
||||||
|
{tab.dirty && <DraftMark />}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,100 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||||
import { api, setUnauthorizedHandler } from '../services/api.js';
|
import { api, setUnauthorizedHandler } from '../services/api.js';
|
||||||
|
import { tokenExpiresAt, isTokenExpired, tokenSubject } from '../utils/jwt.js';
|
||||||
|
import { saveReturnContext, markSessionExpired, consumeReturnContext, clearSessionExpiredFlag } from '../utils/session.js';
|
||||||
|
import { setGuardBypass, clearUserDrafts } from '../utils/drafts.js';
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
const CHECK_EVERY_MS = 30 * 1000;
|
||||||
|
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const userRef = useRef(null);
|
||||||
|
userRef.current = user;
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback((options = {}) => {
|
||||||
|
// Salida voluntaria o sesion invalida: la navegacion a /login no debe pedir confirmacion
|
||||||
|
// (los borradores ya estan guardados en el navegador).
|
||||||
|
setGuardBypass(true);
|
||||||
|
if (!options.preserveDrafts) clearUserDrafts(userRef.current?.id);
|
||||||
api.setToken(null);
|
api.setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
// Vencimiento: recuerda donde estaba el usuario (para volver ahi tras el login) y marca el
|
||||||
setUnauthorizedHandler(() => logout());
|
// aviso que muestra la pantalla de login. Idempotente.
|
||||||
|
const expireSession = useCallback(() => {
|
||||||
|
if (!localStorage.getItem('plc_token')) return;
|
||||||
|
if (userRef.current) saveReturnContext(userRef.current.id);
|
||||||
|
markSessionExpired();
|
||||||
|
logout({ preserveDrafts: true });
|
||||||
}, [logout]);
|
}, [logout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(() => expireSession());
|
||||||
|
}, [expireSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('plc_token');
|
const token = localStorage.getItem('plc_token');
|
||||||
if (!token) { setReady(true); return; }
|
if (!token) { setReady(true); return; }
|
||||||
|
if (isTokenExpired(token)) {
|
||||||
|
// Recarga con la sesion ya vencida (ej. pestaña dormida): igual se recuerda donde estaba.
|
||||||
|
saveReturnContext(tokenSubject(token));
|
||||||
|
markSessionExpired();
|
||||||
|
api.setToken(null);
|
||||||
|
setReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
api.get('/auth/me')
|
api.get('/auth/me')
|
||||||
.then((me) => setUser(me))
|
.then((me) => setUser(me))
|
||||||
.catch(() => logout())
|
.catch(() => logout())
|
||||||
.finally(() => setReady(true));
|
.finally(() => setReady(true));
|
||||||
}, [logout]);
|
}, [logout]);
|
||||||
|
|
||||||
const login = useCallback(async (email, password) => {
|
// Vigilancia del vencimiento aunque no haya peticiones: temporizador al instante exacto, un
|
||||||
const result = await api.post('/auth/login', { email, password });
|
// chequeo periodico y, sobre todo, al volver a la pestaña/ventana -- el navegador congela los
|
||||||
api.setToken(result.token);
|
// temporizadores de pestañas en segundo plano, asi que no se puede confiar solo en ellos.
|
||||||
setUser(result.user);
|
useEffect(() => {
|
||||||
return result.user;
|
if (!user) return undefined;
|
||||||
|
const check = () => {
|
||||||
|
if (isTokenExpired(localStorage.getItem('plc_token'), 0)) expireSession();
|
||||||
|
};
|
||||||
|
const exp = tokenExpiresAt(localStorage.getItem('plc_token'));
|
||||||
|
const timer = exp ? setTimeout(check, Math.min(Math.max(exp - Date.now(), 0) + 500, MAX_TIMEOUT_MS)) : null;
|
||||||
|
const interval = setInterval(check, CHECK_EVERY_MS);
|
||||||
|
const onVisible = () => { if (document.visibilityState === 'visible') check(); };
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
window.addEventListener('focus', check);
|
||||||
|
window.addEventListener('pageshow', check);
|
||||||
|
check();
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
window.removeEventListener('focus', check);
|
||||||
|
window.removeEventListener('pageshow', check);
|
||||||
|
};
|
||||||
|
}, [user, expireSession]);
|
||||||
|
|
||||||
|
// Devuelve la ruta a la que volver tras iniciar sesion (solo si la dejo este mismo usuario).
|
||||||
|
const startSession = useCallback((token, sessionUser) => {
|
||||||
|
api.setToken(token);
|
||||||
|
clearSessionExpiredFlag();
|
||||||
|
setGuardBypass(false);
|
||||||
|
setUser(sessionUser);
|
||||||
|
return consumeReturnContext(sessionUser.id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email, password) => {
|
||||||
|
const result = await api.post('/auth/login', { email, password });
|
||||||
|
const returnTo = startSession(result.token, result.user);
|
||||||
|
return { user: result.user, returnTo };
|
||||||
|
}, [startSession]);
|
||||||
|
|
||||||
// Usado por RegisterPage cuando el registro devuelve sesion directa (primer usuario = superadmin).
|
// Usado por RegisterPage cuando el registro devuelve sesion directa (primer usuario = superadmin).
|
||||||
const setSession = useCallback((token, sessionUser) => {
|
const setSession = useCallback((token, sessionUser) => { startSession(token, sessionUser); }, [startSession]);
|
||||||
api.setToken(token);
|
|
||||||
setUser(sessionUser);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo(() => ({ user, ready, login, logout, setSession }), [user, ready, login, logout, setSession]);
|
const value = useMemo(() => ({ user, ready, login, logout, setSession }), [user, ready, login, logout, setSession]);
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function useAction() {
|
|||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
openDialog({ tone: 'error', title: t('common_error'), message: err.message });
|
openDialog({ tone: err.status === 409 ? 'warning' : 'error', title: t(err.status === 409 ? 'draft_conflict_title' : 'common_error'), message: err.status === 409 ? t('draft_conflict_reload_msg') : err.message });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}, [openDialog, t]);
|
}, [openDialog, t]);
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||||
|
import { useAuth } from '../context/auth-context.jsx';
|
||||||
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
import {
|
||||||
|
draftKey, readDraft, writeDraft, removeDraft, listDrafts, subscribeDrafts,
|
||||||
|
sanitizeDraftValues, fingerprint, setActiveEditor, draftAllowedFields,
|
||||||
|
} from '../utils/drafts.js';
|
||||||
|
|
||||||
|
const same = (a, b) => String(a ?? '') === String(b ?? '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de un formulario con borrador persistente (ver utils/drafts.js).
|
||||||
|
*
|
||||||
|
* - formId: identificador estable del formulario (ej. 'project-info').
|
||||||
|
* - entityId: id del registro que se edita; null/undefined para "nuevo".
|
||||||
|
* - initial: valores del servidor (o vacios para "nuevo"); puede ser un objeto nuevo en cada
|
||||||
|
* render, se compara por huella.
|
||||||
|
* - label: texto que se muestra en el aviso de cambios pendientes.
|
||||||
|
* - enabled: false mientras el formulario no esta abierto (no lee ni escribe borrador).
|
||||||
|
* - exclude: campos que nunca se guardan como borrador (ademas de los secretos por nombre).
|
||||||
|
* - onDiscard: se llama al descartar (ej. desde el aviso al salir) para cerrar la edicion.
|
||||||
|
*/
|
||||||
|
export function useDraftForm({ formId, entityId, initial, label, enabled = true, exclude = [], onDiscard }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { t, openDialog } = useUi();
|
||||||
|
const key = user ? draftKey(user.id, formId, entityId) : null;
|
||||||
|
const excludeKey = exclude.join(',');
|
||||||
|
const allowed = draftAllowedFields(formId);
|
||||||
|
const baseFp = fingerprint(initial, exclude, allowed);
|
||||||
|
const initialRef = useRef(initial);
|
||||||
|
initialRef.current = initial;
|
||||||
|
const onDiscardRef = useRef(onDiscard);
|
||||||
|
onDiscardRef.current = onDiscard;
|
||||||
|
|
||||||
|
const [values, setValuesState] = useState(initial);
|
||||||
|
const [restored, setRestored] = useState(false);
|
||||||
|
const [conflict, setConflict] = useState(false);
|
||||||
|
// Huella del servidor desde la que se empezo a editar (para detectar cambios ajenos al guardar).
|
||||||
|
const startBaseRef = useRef(baseFp);
|
||||||
|
// Huella de lo que ya esta guardado como borrador: si no cambia, no se reescribe (asi se
|
||||||
|
// conserva la URL original donde se estaba editando, aunque se reabra desde otra pestaña).
|
||||||
|
const persistedFpRef = useRef(null);
|
||||||
|
|
||||||
|
// Cargar borrador (o valores del servidor) al abrir el formulario / cambiar de registro.
|
||||||
|
// Se hace en el render (estado derivado), no en un efecto: asi el efecto que persiste nunca ve
|
||||||
|
// los valores viejos y no borra por error el borrador recien cargado.
|
||||||
|
const loadSig = `${key}|${baseFp}|${enabled}`;
|
||||||
|
const [loadedSig, setLoadedSig] = useState(null);
|
||||||
|
if (loadedSig !== loadSig) {
|
||||||
|
setLoadedSig(loadSig);
|
||||||
|
const draft = enabled && key ? readDraft(key) : null;
|
||||||
|
persistedFpRef.current = draft ? fingerprint(draft.values, exclude, allowed) : null;
|
||||||
|
if (draft) {
|
||||||
|
setValuesState({ ...initial, ...draft.values });
|
||||||
|
setRestored(true);
|
||||||
|
startBaseRef.current = draft.base;
|
||||||
|
setConflict(entityId != null && draft.base !== baseFp);
|
||||||
|
} else {
|
||||||
|
setValuesState(initial);
|
||||||
|
setRestored(false);
|
||||||
|
setConflict(false);
|
||||||
|
startBaseRef.current = baseFp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFieldDirty = useCallback((name) => !same(values?.[name], initial?.[name]), [values, initial]);
|
||||||
|
const isDirty = useMemo(
|
||||||
|
() => !!values && Object.keys({ ...values, ...initial }).some((k) => !same(values[k], initial?.[k])),
|
||||||
|
[values, initial]
|
||||||
|
);
|
||||||
|
|
||||||
|
const discard = useCallback(() => {
|
||||||
|
// Sincronico: si a continuacion se navega (ej. tras guardar), el aviso no debe saltar.
|
||||||
|
if (key) { removeDraft(key); setActiveEditor(key, null); }
|
||||||
|
persistedFpRef.current = null;
|
||||||
|
setValuesState(initialRef.current);
|
||||||
|
setRestored(false);
|
||||||
|
setConflict(false);
|
||||||
|
onDiscardRef.current?.();
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
// Persistir y registrar el editor activo mientras haya cambios.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !key) return undefined;
|
||||||
|
const clean = sanitizeDraftValues(values, exclude, allowed);
|
||||||
|
const fp = fingerprint(clean);
|
||||||
|
if (fp === persistedFpRef.current) {
|
||||||
|
// Ya guardado tal cual (ej. recien recuperado): no se reescribe.
|
||||||
|
} else if (fp !== baseFp) {
|
||||||
|
persistedFpRef.current = fp;
|
||||||
|
writeDraft(key, {
|
||||||
|
values: clean,
|
||||||
|
base: startBaseRef.current,
|
||||||
|
label,
|
||||||
|
path: `${window.location.pathname}${window.location.search}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
persistedFpRef.current = null;
|
||||||
|
removeDraft(key);
|
||||||
|
}
|
||||||
|
if (isDirty) setActiveEditor(key, { label, discard });
|
||||||
|
else setActiveEditor(key, null);
|
||||||
|
return () => setActiveEditor(key, null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [values, isDirty, enabled, key, baseFp, label, excludeKey]);
|
||||||
|
|
||||||
|
const setValues = useCallback((updater) => {
|
||||||
|
setValuesState((prev) => (typeof updater === 'function' ? updater(prev) : updater));
|
||||||
|
}, []);
|
||||||
|
const setField = useCallback((name, value) => setValues((v) => ({ ...v, [name]: value })), [setValues]);
|
||||||
|
|
||||||
|
// Tras guardar con exito: el borrador ya no hace falta.
|
||||||
|
const clear = discard;
|
||||||
|
|
||||||
|
const keepMine = useCallback(() => {
|
||||||
|
startBaseRef.current = baseFp;
|
||||||
|
setConflict(false);
|
||||||
|
if (key) {
|
||||||
|
const draft = readDraft(key);
|
||||||
|
if (draft) writeDraft(key, { ...draft, base: baseFp });
|
||||||
|
}
|
||||||
|
}, [key, baseFp]);
|
||||||
|
|
||||||
|
// Envuelve el guardado: si el registro cambio en el servidor desde que se empezo a editar,
|
||||||
|
// pide confirmacion antes de sobrescribir. fetchCurrent (opcional) trae los valores actuales
|
||||||
|
// del servidor (mismo formato que `initial`) para detectarlo tambien sin recargar la pagina.
|
||||||
|
const guardSave = useCallback(async (save, { fetchCurrent } = {}) => {
|
||||||
|
let changedOnServer = conflict;
|
||||||
|
if (!changedOnServer && fetchCurrent && entityId != null) {
|
||||||
|
try {
|
||||||
|
const current = await fetchCurrent();
|
||||||
|
changedOnServer = fingerprint(current, exclude, allowed) !== startBaseRef.current;
|
||||||
|
} catch {
|
||||||
|
openDialog({ tone: 'error', title: t('common_error'), message: t('draft_conflict_check_failed') });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changedOnServer) return save();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
openDialog({
|
||||||
|
tone: 'warning',
|
||||||
|
title: t('draft_conflict_title'),
|
||||||
|
message: values?.editVersion ? t('draft_conflict_reload_msg') : t('draft_conflict_save_msg'),
|
||||||
|
actions: values?.editVersion ? [
|
||||||
|
{ label: t('common_cancel'), variant: 'standard', onClick: () => resolve(undefined) },
|
||||||
|
] : [
|
||||||
|
{ label: t('common_cancel'), variant: 'standard', onClick: () => resolve(undefined) },
|
||||||
|
{ label: t('draft_conflict_overwrite'), variant: 'danger', onClick: async () => { keepMine(); resolve(await save()); } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [conflict, entityId, excludeKey, openDialog, t, keepMine, values]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
values, setValues, setField, isDirty, isFieldDirty, restored, conflict,
|
||||||
|
keepMine, discard, clear, guardSave, draftKey: key, label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lista reactiva de borradores del usuario actual (para marcar pestañas, menus, tarjetas).
|
||||||
|
export function useUserDrafts() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const snapshotRef = useRef({ fp: '', list: [] });
|
||||||
|
const getSnapshot = useCallback(() => {
|
||||||
|
const list = listDrafts(user?.id);
|
||||||
|
const fp = list.map((d) => `${d.key}@${d.savedAt}`).sort().join('|');
|
||||||
|
if (fp !== snapshotRef.current.fp) snapshotRef.current = { fp, list };
|
||||||
|
return snapshotRef.current.list;
|
||||||
|
}, [user?.id]);
|
||||||
|
return useSyncExternalStore(subscribeDrafts, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDraftFor(drafts, formId, entityId) {
|
||||||
|
return drafts.some((d) => d.formId === formId && (entityId === undefined || String(d.entityId) === String(entityId ?? null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reabre, una sola vez al entrar a la pantalla, el popup que quedo con borrador:
|
||||||
|
// - newFormId: borrador de "Nuevo ..." -> openNew()
|
||||||
|
// - editFormId: borrador de edicion -> openEdit(item), SOLO si el registro esta en `items` (la
|
||||||
|
// lista que el backend devolvio para este usuario: si ya no existe o perdio el acceso, no se
|
||||||
|
// restaura nada).
|
||||||
|
export function useReopenDraft({ newFormId, editFormId, items, openNew, openEdit }) {
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
const done = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (done.current) return;
|
||||||
|
if (newFormId && hasDraftFor(drafts, newFormId, null)) { done.current = true; openNew(); return; }
|
||||||
|
if (!editFormId) { done.current = true; return; }
|
||||||
|
if (!items) return;
|
||||||
|
done.current = true;
|
||||||
|
const d = drafts.find((x) => x.formId === editFormId && items.some((i) => String(i.id) === String(x.entityId)));
|
||||||
|
if (d) openEdit(items.find((i) => String(i.id) === String(d.entityId)));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [items, drafts]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useModalFocus(open, onEscape) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const escapeRef = useRef(onEscape);
|
||||||
|
escapeRef.current = onEscape;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const previous = document.activeElement;
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const root = ref.current;
|
||||||
|
if (root && !root.contains(document.activeElement)) {
|
||||||
|
(root.querySelector('[autofocus]') || root.querySelector('button, input, textarea, select, [tabindex="0"]') || root).focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const handleKey = (event) => {
|
||||||
|
const root = ref.current;
|
||||||
|
if (!root) return;
|
||||||
|
const dialogs = [...document.querySelectorAll('[aria-modal="true"]')];
|
||||||
|
if (dialogs.at(-1) !== root) return;
|
||||||
|
if (event.key === 'Escape' && escapeRef.current) { event.preventDefault(); escapeRef.current(); return; }
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
const focusable = [...root.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex="0"]')]
|
||||||
|
.filter((element) => element.getClientRects().length);
|
||||||
|
if (!focusable.length) { event.preventDefault(); root.focus(); return; }
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable.at(-1);
|
||||||
|
if (event.shiftKey && (document.activeElement === first || !root.contains(document.activeElement))) { event.preventDefault(); last.focus(); }
|
||||||
|
else if (!event.shiftKey && (document.activeElement === last || !root.contains(document.activeElement))) { event.preventDefault(); first.focus(); }
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => { cancelAnimationFrame(frame); document.removeEventListener('keydown', handleKey); previous?.focus?.(); };
|
||||||
|
}, [open]);
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ export const messages = {
|
|||||||
config_company: 'Empresa',
|
config_company: 'Empresa',
|
||||||
company_name: 'Nombre de la empresa',
|
company_name: 'Nombre de la empresa',
|
||||||
company_logo: 'Logo',
|
company_logo: 'Logo',
|
||||||
company_logo_hint: 'PNG, JPG, WEBP o SVG. Se usa en el menu lateral y en las pantallas de acceso.',
|
company_logo_hint: 'PNG, JPG o WEBP. Se usa en el menu lateral y en las pantallas de acceso.',
|
||||||
company_logo_none: 'Sin logo',
|
company_logo_none: 'Sin logo',
|
||||||
company_logo_choose: 'Elegir imagen',
|
company_logo_choose: 'Elegir imagen',
|
||||||
|
|
||||||
@@ -117,6 +117,8 @@ export const messages = {
|
|||||||
common_delete: 'Eliminar',
|
common_delete: 'Eliminar',
|
||||||
common_close: 'Cerrar',
|
common_close: 'Cerrar',
|
||||||
common_search: 'Buscar...',
|
common_search: 'Buscar...',
|
||||||
|
common_select: 'Seleccionar',
|
||||||
|
common_actions: 'Acciones',
|
||||||
common_search_no_results: 'Sin resultados',
|
common_search_no_results: 'Sin resultados',
|
||||||
common_actions: 'Acciones',
|
common_actions: 'Acciones',
|
||||||
common_view: 'Ver',
|
common_view: 'Ver',
|
||||||
@@ -155,11 +157,31 @@ export const messages = {
|
|||||||
dashboard_today_activities: 'Para hoy',
|
dashboard_today_activities: 'Para hoy',
|
||||||
dashboard_average_progress: 'Avance promedio',
|
dashboard_average_progress: 'Avance promedio',
|
||||||
common_of: 'de',
|
common_of: 'de',
|
||||||
|
draft_leave_title: 'Cambios sin guardar',
|
||||||
|
draft_leave_msg: 'Vas a salir de una sección con cambios que todavía no guardaste:',
|
||||||
|
draft_leave_hint: 'Si los dejas pendientes, se conservan en este navegador y se reabre la edición al volver.',
|
||||||
|
draft_discard: 'Descartar cambios',
|
||||||
|
draft_keep_pending: 'Dejar pendiente',
|
||||||
|
draft_keep_editing: 'Seguir editando',
|
||||||
|
draft_pending_mark: 'Cambios sin guardar',
|
||||||
|
draft_field_modified: 'modificado',
|
||||||
|
draft_restored_msg: 'Se recuperaron cambios que no habías guardado. Revisa y guarda, o descártalos.',
|
||||||
|
draft_conflict_title: 'El registro cambió',
|
||||||
|
draft_conflict_reload_msg: 'El registro cambió en el servidor. Recarga para revisar la versión actual antes de guardar. Tus cambios pendientes se conservarán.',
|
||||||
|
draft_conflict_reload: 'Descartar y recargar',
|
||||||
|
draft_conflict_msg: 'Este registro cambió en el servidor desde que empezaste a editarlo. Si guardas tus cambios pendientes, reemplazarás esos cambios.',
|
||||||
|
draft_conflict_save_msg: 'Este registro cambió en el servidor desde que empezaste a editarlo (otra persona u otra pestaña). ¿Quieres sobrescribirlo con tus cambios?',
|
||||||
|
draft_conflict_overwrite: 'Sobrescribir',
|
||||||
|
draft_keep_mine: 'Mantener mis cambios',
|
||||||
|
draft_use_server: 'Descartar mis cambios',
|
||||||
|
session_expired_login_msg: 'Tu sesión venció. Inicia sesión otra vez para volver a donde estabas; los cambios sin guardar se conservaron.',
|
||||||
dashboard_cards: 'Tarjetas',
|
dashboard_cards: 'Tarjetas',
|
||||||
dashboard_no_cards: 'No hay tarjetas seleccionadas. Elige cuáles mostrar en "Tarjetas".',
|
dashboard_no_cards: 'No hay tarjetas seleccionadas. Elige cuáles mostrar en "Tarjetas".',
|
||||||
dashboard_projects_by_status: 'Proyectos por estado',
|
dashboard_projects_by_status: 'Proyectos por estado',
|
||||||
|
|
||||||
projects_title: 'Proyectos',
|
projects_title: 'Proyectos',
|
||||||
|
project_page_range: 'Mostrando', project_page_of: 'de', project_page_previous: 'Anterior', project_page_next: 'Siguiente',
|
||||||
|
draft_conflict_check_failed: 'No se pudo comprobar la versión actual. Intenta de nuevo.',
|
||||||
project_code: 'Codigo',
|
project_code: 'Codigo',
|
||||||
project_new: 'Nuevo proyecto',
|
project_new: 'Nuevo proyecto',
|
||||||
project_objective: 'Objetivo',
|
project_objective: 'Objetivo',
|
||||||
@@ -541,7 +563,7 @@ export const messages = {
|
|||||||
config_company: 'Company',
|
config_company: 'Company',
|
||||||
company_name: 'Company name',
|
company_name: 'Company name',
|
||||||
company_logo: 'Logo',
|
company_logo: 'Logo',
|
||||||
company_logo_hint: 'PNG, JPG, WEBP or SVG. Used in the sidebar and on the sign-in screens.',
|
company_logo_hint: 'PNG, JPG or WEBP. Used in the sidebar and on the sign-in screens.',
|
||||||
company_logo_none: 'No logo',
|
company_logo_none: 'No logo',
|
||||||
company_logo_choose: 'Choose image',
|
company_logo_choose: 'Choose image',
|
||||||
|
|
||||||
@@ -604,6 +626,8 @@ export const messages = {
|
|||||||
common_delete: 'Delete',
|
common_delete: 'Delete',
|
||||||
common_close: 'Close',
|
common_close: 'Close',
|
||||||
common_search: 'Search...',
|
common_search: 'Search...',
|
||||||
|
common_select: 'Select',
|
||||||
|
common_actions: 'Actions',
|
||||||
common_search_no_results: 'No results',
|
common_search_no_results: 'No results',
|
||||||
common_actions: 'Actions',
|
common_actions: 'Actions',
|
||||||
common_view: 'View',
|
common_view: 'View',
|
||||||
@@ -642,11 +666,31 @@ export const messages = {
|
|||||||
dashboard_today_activities: 'Due today',
|
dashboard_today_activities: 'Due today',
|
||||||
dashboard_average_progress: 'Average progress',
|
dashboard_average_progress: 'Average progress',
|
||||||
common_of: 'of',
|
common_of: 'of',
|
||||||
|
draft_leave_title: 'Unsaved changes',
|
||||||
|
draft_leave_msg: 'You are leaving a section with changes you have not saved yet:',
|
||||||
|
draft_leave_hint: 'If you leave them pending, they are kept in this browser and editing reopens when you come back.',
|
||||||
|
draft_discard: 'Discard changes',
|
||||||
|
draft_keep_pending: 'Leave pending',
|
||||||
|
draft_keep_editing: 'Keep editing',
|
||||||
|
draft_pending_mark: 'Unsaved changes',
|
||||||
|
draft_field_modified: 'modified',
|
||||||
|
draft_restored_msg: 'Unsaved changes were recovered. Review and save them, or discard them.',
|
||||||
|
draft_conflict_title: 'The record changed',
|
||||||
|
draft_conflict_reload_msg: 'The record changed on the server. Reload to review the current version before saving. Your pending changes will be kept.',
|
||||||
|
draft_conflict_reload: 'Discard and reload',
|
||||||
|
draft_conflict_msg: 'This record changed on the server since you started editing it. Saving your pending changes will replace those changes.',
|
||||||
|
draft_conflict_save_msg: 'This record changed on the server since you started editing it (someone else or another tab). Do you want to overwrite it with your changes?',
|
||||||
|
draft_conflict_overwrite: 'Overwrite',
|
||||||
|
draft_keep_mine: 'Keep my changes',
|
||||||
|
draft_use_server: 'Discard my changes',
|
||||||
|
session_expired_login_msg: 'Your session expired. Sign in again to return where you were; unsaved changes were kept.',
|
||||||
dashboard_cards: 'Cards',
|
dashboard_cards: 'Cards',
|
||||||
dashboard_no_cards: 'No cards selected. Choose which ones to show in "Cards".',
|
dashboard_no_cards: 'No cards selected. Choose which ones to show in "Cards".',
|
||||||
dashboard_projects_by_status: 'Projects by status',
|
dashboard_projects_by_status: 'Projects by status',
|
||||||
|
|
||||||
projects_title: 'Projects',
|
projects_title: 'Projects',
|
||||||
|
project_page_range: 'Showing', project_page_of: 'of', project_page_previous: 'Previous', project_page_next: 'Next',
|
||||||
|
draft_conflict_check_failed: 'Could not check the current version. Try again.',
|
||||||
project_code: 'Code',
|
project_code: 'Code',
|
||||||
project_new: 'New project',
|
project_new: 'New project',
|
||||||
project_objective: 'Objective',
|
project_objective: 'Objective',
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
|||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||||
|
|
||||||
const COLOR_OPTIONS = ['accent', 'success', 'warning', 'danger', 'muted'].map((c) => ({ value: c, label: c }));
|
const COLOR_OPTIONS = ['accent', 'success', 'warning', 'danger', 'muted'].map((c) => ({ value: c, label: c }));
|
||||||
|
|
||||||
@@ -25,30 +28,39 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
|||||||
const { data: items, reload } = useAsync(() => listCatalog(catalogKey), [catalogKey]);
|
const { data: items, reload } = useAsync(() => listCatalog(catalogKey), [catalogKey]);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [form, setForm] = useState(emptyForm(fields));
|
const guardedClose = useGuardedClose();
|
||||||
|
// Borrador del item (nuevo o edicion), separado por catalogo.
|
||||||
|
const fromItem = (item) => { const next = {}; for (const f of fields) next[f.key] = item[f.key]; next.editVersion = item.edit_version; return next; };
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: `catalog-${catalogKey}-${selected ? 'edit' : 'new'}`, entityId: selected?.id ?? null,
|
||||||
|
initial: selected ? fromItem(selected) : emptyForm(fields),
|
||||||
|
label: `${title}: ${selected ? t('common_edit') : t('common_new')}`, enabled: editing,
|
||||||
|
});
|
||||||
|
const form = draft.values;
|
||||||
|
const setForm = draft.setValues;
|
||||||
|
const pendingNew = hasDraftFor(useUserDrafts(), `catalog-${catalogKey}-new`, null);
|
||||||
|
useReopenDraft({ newFormId: `catalog-${catalogKey}-new`, editFormId: `catalog-${catalogKey}-edit`, items, openNew: () => startNew(), openEdit: (i) => startEdit(i) });
|
||||||
|
|
||||||
function startNew() {
|
function startNew() {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setForm(emptyForm(fields));
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(item) {
|
function startEdit(item) {
|
||||||
setSelected(item);
|
setSelected(item);
|
||||||
const next = {};
|
|
||||||
for (const f of fields) next[f.key] = item[f.key];
|
|
||||||
setForm(next);
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
await runAction(async () => {
|
const run = () => runAction(async () => {
|
||||||
if (selected) await updateCatalogItem(catalogKey, selected.id, form);
|
if (selected) await updateCatalogItem(catalogKey, selected.id, form);
|
||||||
else await createCatalogItem(catalogKey, form);
|
else await createCatalogItem(catalogKey, form);
|
||||||
|
draft.clear();
|
||||||
await reload();
|
await reload();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
});
|
});
|
||||||
|
if (selected) await draft.guardSave(run); else await run();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(item) {
|
function confirmDelete(item) {
|
||||||
@@ -60,6 +72,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
|||||||
{ label: t('common_cancel'), variant: 'standard' },
|
{ label: t('common_cancel'), variant: 'standard' },
|
||||||
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => {
|
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => {
|
||||||
await deleteCatalogItem(catalogKey, item.id);
|
await deleteCatalogItem(catalogKey, item.id);
|
||||||
|
draft.discard();
|
||||||
await reload();
|
await reload();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}) },
|
}) },
|
||||||
@@ -81,7 +94,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<h4 style={{ margin: 0 }}>{title}</h4>
|
<h4 style={{ margin: 0 }}>{title}</h4>
|
||||||
<Button size="sm" variant="accent" icon={Plus} onClick={startNew}>{t('common_new')}</Button>
|
<Button size="sm" variant="accent" icon={Plus} onClick={startNew}>{t('common_new')}{pendingNew && !editing && <DraftMark />}</Button>
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<ListView
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -94,10 +107,11 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('common_new')} onClose={() => setEditing(false)} width={480} height={380}>
|
<DetailPopup open={editing} title={selected ? t('common_edit') : t('common_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={480} height={420}>
|
||||||
|
<DraftNotice draft={draft} />
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
{fields.map((f) => (
|
{fields.map((f) => (
|
||||||
<Field key={f.key} label={f.label}>
|
<Field key={f.key} label={f.label} dirty={draft.isFieldDirty(f.key)}>
|
||||||
{f.type === 'color' ? (
|
{f.type === 'color' ? (
|
||||||
<ComboBox options={COLOR_OPTIONS} value={form[f.key]} onChange={(v) => setForm((s) => ({ ...s, [f.key]: v }))} />
|
<ComboBox options={COLOR_OPTIONS} value={form[f.key]} onChange={(v) => setForm((s) => ({ ...s, [f.key]: v }))} />
|
||||||
) : f.type === 'checkbox' ? (
|
) : f.type === 'checkbox' ? (
|
||||||
@@ -118,7 +132,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
|
|||||||
</FieldRow>
|
</FieldRow>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button size="sm" variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
<Button size="sm" variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||||
<Button size="sm" variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
<Button size="sm" variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,21 +6,25 @@ import { useAsync, useAction } from '../../hooks/useAsync.js';
|
|||||||
import { getCompanyInfo, updateCompanyInfo } from '../../services/company.js';
|
import { getCompanyInfo, updateCompanyInfo } from '../../services/company.js';
|
||||||
import { Field } from '../../components/Field.jsx';
|
import { Field } from '../../components/Field.jsx';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
|
||||||
export default function CompanySettingsPanel() {
|
export default function CompanySettingsPanel() {
|
||||||
const { t, pushToast } = useUi();
|
const { t, pushToast } = useUi();
|
||||||
const branding = useBranding();
|
const branding = useBranding();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: company, reload } = useAsync(getCompanyInfo, []);
|
const { data: company, reload } = useAsync(getCompanyInfo, []);
|
||||||
const [name, setName] = useState('');
|
// Borrador del nombre. El logo (archivo) no se puede guardar como borrador: se vuelve a elegir.
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: 'company-settings', entityId: 'system', initial: { name: company?.name || '', editVersion: company?.edit_version },
|
||||||
|
label: t('config_company'), enabled: !!company,
|
||||||
|
});
|
||||||
|
const name = draft.values.name;
|
||||||
|
const setName = (v) => draft.setField('name', v);
|
||||||
const [logoFile, setLogoFile] = useState(null);
|
const [logoFile, setLogoFile] = useState(null);
|
||||||
const [preview, setPreview] = useState(null);
|
const [preview, setPreview] = useState(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (company) setName(company.name);
|
|
||||||
}, [company]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!logoFile) { setPreview(null); return; }
|
if (!logoFile) { setPreview(null); return; }
|
||||||
const url = URL.createObjectURL(logoFile);
|
const url = URL.createObjectURL(logoFile);
|
||||||
@@ -29,14 +33,15 @@ export default function CompanySettingsPanel() {
|
|||||||
}, [logoFile]);
|
}, [logoFile]);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
await runAction(async () => {
|
await draft.guardSave(() => runAction(async () => {
|
||||||
await updateCompanyInfo({ name, logoFile });
|
await updateCompanyInfo({ name, logoFile, editVersion: draft.values.editVersion });
|
||||||
|
draft.clear();
|
||||||
setLogoFile(null);
|
setLogoFile(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
await reload();
|
await reload();
|
||||||
await branding.reload();
|
await branding.reload();
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
});
|
}), { fetchCurrent: async () => { const c = await getCompanyInfo(); return { name: c.name || '', editVersion: c.edit_version }; } });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!company) return <div className="text-muted">{t('common_loading')}</div>;
|
if (!company) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
@@ -45,7 +50,8 @@ export default function CompanySettingsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Field label={t('company_name')}>
|
<DraftNotice draft={draft} />
|
||||||
|
<Field label={t('company_name')} dirty={draft.isFieldDirty('name')}>
|
||||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="LinkedCorp" />
|
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="LinkedCorp" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('company_logo')} hint={t('company_logo_hint')}>
|
<Field label={t('company_logo')} hint={t('company_logo_hint')}>
|
||||||
@@ -56,7 +62,7 @@ export default function CompanySettingsPanel() {
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
accept="image/png,image/jpeg,image/webp"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={(e) => setLogoFile(e.target.files?.[0] || null)}
|
onChange={(e) => setLogoFile(e.target.files?.[0] || null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function ConfiguracionPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('config_title')}</h2>
|
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('config_title')}</h2>
|
||||||
|
|
||||||
<Expander icon={Building2} title={t('config_company')} defaultOpen>
|
<Expander icon={Building2} title={t('config_company')} defaultOpen draftForms={['company-settings']}>
|
||||||
<CompanySettingsPanel />
|
<CompanySettingsPanel />
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export default function ConfiguracionPage() {
|
|||||||
</FieldRow>
|
</FieldRow>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
<Expander icon={Flag} title={t('config_priorities')}>
|
<Expander icon={Flag} title={t('config_priorities')} draftForms={['catalog-priorities-']}>
|
||||||
<CatalogEditor
|
<CatalogEditor
|
||||||
catalogKey="priorities"
|
catalogKey="priorities"
|
||||||
title={t('config_priorities')}
|
title={t('config_priorities')}
|
||||||
@@ -81,7 +81,7 @@ export default function ConfiguracionPage() {
|
|||||||
/>
|
/>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
<Expander icon={ListChecks} title={t('config_project_statuses')}>
|
<Expander icon={ListChecks} title={t('config_project_statuses')} draftForms={['catalog-project-statuses-']}>
|
||||||
<CatalogEditor
|
<CatalogEditor
|
||||||
catalogKey="project-statuses"
|
catalogKey="project-statuses"
|
||||||
title={t('config_project_statuses')}
|
title={t('config_project_statuses')}
|
||||||
@@ -94,7 +94,7 @@ export default function ConfiguracionPage() {
|
|||||||
/>
|
/>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
<Expander icon={Layers} title={t('config_activity_statuses')}>
|
<Expander icon={Layers} title={t('config_activity_statuses')} draftForms={['catalog-activity-statuses-']}>
|
||||||
<CatalogEditor
|
<CatalogEditor
|
||||||
catalogKey="activity-statuses"
|
catalogKey="activity-statuses"
|
||||||
title={t('config_activity_statuses')}
|
title={t('config_activity_statuses')}
|
||||||
@@ -107,7 +107,7 @@ export default function ConfiguracionPage() {
|
|||||||
/>
|
/>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
<Expander icon={FolderKanban} title={t('config_project_types')}>
|
<Expander icon={FolderKanban} title={t('config_project_types')} draftForms={['catalog-project-types-']}>
|
||||||
<CatalogEditor
|
<CatalogEditor
|
||||||
catalogKey="project-types"
|
catalogKey="project-types"
|
||||||
title={t('config_project_types')}
|
title={t('config_project_types')}
|
||||||
@@ -115,7 +115,7 @@ export default function ConfiguracionPage() {
|
|||||||
/>
|
/>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
<Expander icon={Mail} title={t('config_smtp')}>
|
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
|
||||||
<SmtpSettingsPanel />
|
<SmtpSettingsPanel />
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Save, Send } from 'lucide-react';
|
import { Save, Send } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
@@ -7,27 +7,32 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
|||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
|
||||||
export default function SmtpSettingsPanel() {
|
export default function SmtpSettingsPanel() {
|
||||||
const { t, pushToast } = useUi();
|
const { t, pushToast } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: config, reload } = useAsync(getSmtpConfig, []);
|
const { data: config, reload } = useAsync(getSmtpConfig, []);
|
||||||
const [form, setForm] = useState({ host: '', port: 587, user: '', from: '', pass: '' });
|
// Borrador de la configuracion SMTP; la clave (pass) nunca se guarda como borrador.
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: 'smtp-config', entityId: 'system',
|
||||||
|
initial: config ? { host: config.host, port: config.port, user: config.user, from: config.from, pass: '', editVersion: config.edit_version } : { host: '', port: 587, user: '', from: '', pass: '' },
|
||||||
|
label: t('config_smtp'), enabled: !!config, exclude: ['pass'],
|
||||||
|
});
|
||||||
|
const form = draft.values;
|
||||||
|
const setForm = draft.setValues;
|
||||||
const [testTo, setTestTo] = useState('');
|
const [testTo, setTestTo] = useState('');
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState(null); // { ok: bool, message: string }
|
const [testResult, setTestResult] = useState(null); // { ok: bool, message: string }
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (config) setForm({ host: config.host, port: config.port, user: config.user, from: config.from, pass: '' });
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
await runAction(async () => {
|
await draft.guardSave(() => runAction(async () => {
|
||||||
await updateSmtpConfig(form);
|
await updateSmtpConfig(form);
|
||||||
|
draft.clear();
|
||||||
await reload();
|
await reload();
|
||||||
setForm((f) => ({ ...f, pass: '' }));
|
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
});
|
}), { fetchCurrent: async () => { const c = await getSmtpConfig(); return { host: c.host, port: c.port, user: c.user, from: c.from, pass: '', editVersion: c.edit_version }; } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendTest() {
|
async function sendTest() {
|
||||||
@@ -50,23 +55,24 @@ export default function SmtpSettingsPanel() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!config.configured && <InfoBar tone="warning" dismissible={false}>{t('smtp_not_configured')}</InfoBar>}
|
{!config.configured && <InfoBar tone="warning" dismissible={false}>{t('smtp_not_configured')}</InfoBar>}
|
||||||
|
<DraftNotice draft={draft} />
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('smtp_host')}>
|
<Field label={t('smtp_host')} dirty={draft.isFieldDirty('host')}>
|
||||||
<input className="input" value={form.host} onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))} placeholder="192.168.222.10" />
|
<input className="input" value={form.host} onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))} placeholder="192.168.222.10" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('smtp_port')}>
|
<Field label={t('smtp_port')} dirty={draft.isFieldDirty('port')}>
|
||||||
<input className="input" type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))} />
|
<input className="input" type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('smtp_user')}>
|
<Field label={t('smtp_user')} dirty={draft.isFieldDirty('user')}>
|
||||||
<input className="input" value={form.user} onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))} placeholder="project@linkedcorp.net" />
|
<input className="input" value={form.user} onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))} placeholder="project@linkedcorp.net" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('smtp_from')}>
|
<Field label={t('smtp_from')} dirty={draft.isFieldDirty('from')}>
|
||||||
<input className="input" value={form.from} onChange={(e) => setForm((f) => ({ ...f, from: e.target.value }))} placeholder="project@linkedcorp.net" />
|
<input className="input" value={form.from} onChange={(e) => setForm((f) => ({ ...f, from: e.target.value }))} placeholder="project@linkedcorp.net" />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<Field label={t('smtp_pass')} hint={config.configured ? t('smtp_pass_configured_hint') : undefined}>
|
<Field label={t('smtp_pass')} dirty={draft.isFieldDirty('pass')} hint={config.configured ? t('smtp_pass_configured_hint') : undefined}>
|
||||||
<PasswordInput value={form.pass} onChange={(e) => setForm((f) => ({ ...f, pass: e.target.value }))} />
|
<PasswordInput value={form.pass} onChange={(e) => setForm((f) => ({ ...f, pass: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react';
|
import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
@@ -15,6 +16,8 @@ import { Button } from '../../components/Button.jsx';
|
|||||||
import { Field } from '../../components/Field.jsx';
|
import { Field } from '../../components/Field.jsx';
|
||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
import { ListView } from '../../components/ListView.jsx';
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
import { Tabs } from '../../components/Tabs.jsx';
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
@@ -53,56 +56,56 @@ function SimpleTable({ columns, rows, empty }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceDetail({ id, onClose, onChanged, canEdit }) {
|
function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabChange }) {
|
||||||
const { t, pushToast } = useUi();
|
const { t, pushToast } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
|
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
|
||||||
const { data: users } = useAsync(listUsers, []);
|
const { data: users } = useAsync(listUsers, []);
|
||||||
const { data: departments } = useAsync(listDepartments, []);
|
const { data: departments } = useAsync(listDepartments, []);
|
||||||
const [tab, setTab] = useState('general');
|
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
|
||||||
const [searches, setSearches] = useState({});
|
const [searches, setSearches] = useState({});
|
||||||
const [showComponents, setShowComponents] = useState(false);
|
const [showComponents, setShowComponents] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [form, setForm] = useState({});
|
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: 'device-edit', entityId: id,
|
||||||
|
initial: device ? { status: device.status, assignedUserId: device.assigned_user_id || '', departmentId: device.department_id || '', notes: device.notes || '', editVersion: device.edit_version } : {},
|
||||||
|
label: `${t('eq_detail')}: ${device?.hostname || id}`, enabled: !!device && canEdit && tab === 'general', onDiscard: () => setEditing(false),
|
||||||
|
});
|
||||||
|
const form = draft.values;
|
||||||
|
const setForm = draft.setValues;
|
||||||
|
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
|
||||||
|
|
||||||
const flat = (list) => (list || []).flatMap((d) => [d, ...(d.children ? flat(d.children) : [])]);
|
const flat = (list) => (list || []).flatMap((d) => [d, ...(d.children ? flat(d.children) : [])]);
|
||||||
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
|
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
|
||||||
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
|
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
setForm({
|
|
||||||
status: device.status,
|
|
||||||
assignedUserId: device.assigned_user_id || '',
|
|
||||||
departmentId: device.department_id || '',
|
|
||||||
notes: device.notes || '',
|
|
||||||
});
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
const changed = form.status !== device.status
|
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
|
||||||
|| Number(form.assignedUserId || 0) !== Number(device.assigned_user_id || 0)
|
draft.guardSave(() => runAction(async () => {
|
||||||
|| Number(form.departmentId || 0) !== Number(device.department_id || 0)
|
|
||||||
|| form.notes !== (device.notes || '');
|
|
||||||
if (!changed) { setEditing(false); return; }
|
|
||||||
runAction(async () => {
|
|
||||||
await updateDevice(id, {
|
await updateDevice(id, {
|
||||||
status: form.status,
|
status: form.status,
|
||||||
assignedUserId: form.assignedUserId ? Number(form.assignedUserId) : null,
|
assignedUserId: form.assignedUserId ? Number(form.assignedUserId) : null,
|
||||||
departmentId: form.departmentId ? Number(form.departmentId) : null,
|
departmentId: form.departmentId ? Number(form.departmentId) : null,
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
|
editVersion: form.editVersion,
|
||||||
});
|
});
|
||||||
|
draft.clear();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
await reload();
|
await reload();
|
||||||
onChanged();
|
onChanged();
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
});
|
}), { fetchCurrent: async () => { const d = await getDevice(id); return { status: d.status, assignedUserId: d.assigned_user_id || '', departmentId: d.department_id || '', notes: d.notes || '' }; } });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!device) return <div className="text-muted">{t('common_loading')}</div>;
|
if (!device) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'general', label: t('eq_tab_general') },
|
{ key: 'general', label: t('eq_tab_general'), dirty: editing && draft.isDirty },
|
||||||
{ key: 'users', label: t('eq_tab_users'), count: device.accounts.filter((a) => a.kind === 'local_user').length },
|
{ key: 'users', label: t('eq_tab_users'), count: device.accounts.filter((a) => a.kind === 'local_user').length },
|
||||||
{ key: 'hardware', label: t('eq_tab_hardware') },
|
{ key: 'hardware', label: t('eq_tab_hardware') },
|
||||||
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
|
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
|
||||||
@@ -191,26 +194,27 @@ function DeviceDetail({ id, onClose, onChanged, canEdit }) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Field label={t('common_status')}>
|
<DraftNotice draft={draft} />
|
||||||
|
<Field label={t('common_status')} dirty={draft.isFieldDirty('status')}>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
options={[{ value: 'active', label: t('eq_status_active') }, { value: 'retired', label: t('eq_status_retired') }]}
|
options={[{ value: 'active', label: t('eq_status_active') }, { value: 'retired', label: t('eq_status_retired') }]}
|
||||||
value={form.status} onChange={(v) => setForm({ ...form, status: v })}
|
value={form.status} onChange={(v) => setForm({ ...form, status: v })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('eq_assigned_to')}>
|
<Field label={t('eq_assigned_to')} dirty={draft.isFieldDirty('assignedUserId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.assignedUserId} onChange={(v) => setForm({ ...form, assignedUserId: v })} />
|
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.assignedUserId} onChange={(v) => setForm({ ...form, assignedUserId: v })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_department')}>
|
<Field label={t('common_department')} dirty={draft.isFieldDirty('departmentId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm({ ...form, departmentId: v })} />
|
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm({ ...form, departmentId: v })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Row label={t('eq_first_seen')}>{fmtDate(device.first_seen)}</Row>
|
<Row label={t('eq_first_seen')}>{fmtDate(device.first_seen)}</Row>
|
||||||
<Row label={t('eq_last_seen')}>{fmtDate(device.last_seen)}</Row>
|
<Row label={t('eq_last_seen')}>{fmtDate(device.last_seen)}</Row>
|
||||||
<Field label={t('eq_notes')}>
|
<Field label={t('eq_notes')} dirty={draft.isFieldDirty('notes')}>
|
||||||
<textarea className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
<textarea className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -315,8 +319,28 @@ function DevicesTab({ canEdit }) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [kind, setKind] = useState('');
|
const [kind, setKind] = useState('');
|
||||||
const [status, setStatus] = useState('active');
|
const [status, setStatus] = useState('active');
|
||||||
const [selected, setSelected] = useState(null); // { id, view: 'info' }
|
// Equipo abierto y su pestaña en la URL (?device=&deviceTab=): F5, retorno tras el login y
|
||||||
|
// aviso de cambios sin guardar al cerrar el popup.
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const deviceId = searchParams.get('device') ? Number(searchParams.get('device')) : null;
|
||||||
|
const deviceTab = searchParams.get('deviceTab') || 'general';
|
||||||
|
const setDeviceParams = (id, tab) => setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (id) { next.set('device', id); if (tab && tab !== 'general') next.set('deviceTab', tab); else next.delete('deviceTab'); }
|
||||||
|
else { next.delete('device'); next.delete('deviceTab'); }
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
const selected = deviceId ? { id: deviceId, view: 'info' } : null;
|
||||||
|
const setSelected = (v) => setDeviceParams(v?.id || null);
|
||||||
const { data: devices, reload } = useAsync(() => listDevices({ kind, status }), [kind, status]);
|
const { data: devices, reload } = useAsync(() => listDevices({ kind, status }), [kind, status]);
|
||||||
|
// Al volver con un borrador de un equipo (que el usuario sigue viendo), se reabre su popup.
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!devices || deviceId) return;
|
||||||
|
const d = drafts.find((x) => x.formId === 'device-edit' && devices.some((dv) => String(dv.id) === String(x.entityId)));
|
||||||
|
if (d) setDeviceParams(Number(d.entityId));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [devices]);
|
||||||
|
|
||||||
const kindOptions = [{ value: '', label: t('common_all') }, ...KINDS.map((k) => ({ value: k, label: t(`eq_kind_${k}`) }))];
|
const kindOptions = [{ value: '', label: t('common_all') }, ...KINDS.map((k) => ({ value: k, label: t(`eq_kind_${k}`) }))];
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
@@ -352,7 +376,12 @@ function DevicesTab({ canEdit }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailPopup open={selected?.view === 'info'} title={t('eq_detail')} onClose={() => setSelected(null)} width={860} height={680}>
|
<DetailPopup open={selected?.view === 'info'} title={t('eq_detail')} onClose={() => setSelected(null)} width={860} height={680}>
|
||||||
{selected?.view === 'info' && <DeviceDetail key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} canEdit={canEdit} />}
|
{selected?.view === 'info' && (
|
||||||
|
<DeviceDetail
|
||||||
|
key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} canEdit={canEdit}
|
||||||
|
tab={deviceTab} onTabChange={(k) => setDeviceParams(selected.id, k)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -566,12 +595,16 @@ function AgentsTab() {
|
|||||||
export default function EquiposPage() {
|
export default function EquiposPage() {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const { data: access } = useAsync(getDevicesAccess, []);
|
const { data: access } = useAsync(getDevicesAccess, []);
|
||||||
const [tab, setTab] = useState('summary');
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const tab = searchParams.get('tab') || 'summary';
|
||||||
|
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
const devicesDirty = drafts.some((d) => d.formId === 'device-edit');
|
||||||
if (!access) return <div className="text-muted">{t('common_loading')}</div>;
|
if (!access) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
if (!access.canView) return <Navigate to="/" replace />;
|
if (!access.canView) return <Navigate to="/" replace />;
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'summary', label: t('eq_tab_summary') },
|
{ key: 'summary', label: t('eq_tab_summary') },
|
||||||
{ key: 'devices', label: t('eq_tab_devices') },
|
{ key: 'devices', label: t('eq_tab_devices'), dirty: devicesDirty },
|
||||||
{ key: 'software', label: t('eq_tab_software_all') },
|
{ key: 'software', label: t('eq_tab_software_all') },
|
||||||
...(access.canManage ? [{ key: 'agents', label: t('eq_tab_agents') }, { key: 'share', label: t('eq_tab_share') }] : []),
|
...(access.canManage ? [{ key: 'agents', label: t('eq_tab_agents') }, { key: 'share', label: t('eq_tab_share') }] : []),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { LogIn } from 'lucide-react';
|
import { LogIn } from 'lucide-react';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
@@ -7,6 +7,8 @@ import { useBranding } from '../../context/branding-context.jsx';
|
|||||||
import { Field } from '../../components/Field.jsx';
|
import { Field } from '../../components/Field.jsx';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { readSessionExpiredFlag } from '../../utils/session.js';
|
||||||
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||||
import logoLinkedCorpIcon from '../../assets/logoLinkedTrackIcon.png';
|
import logoLinkedCorpIcon from '../../assets/logoLinkedTrackIcon.png';
|
||||||
@@ -16,6 +18,9 @@ export default function LoginPage() {
|
|||||||
const { t, theme } = useUi();
|
const { t, theme } = useUi();
|
||||||
const branding = useBranding();
|
const branding = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
// Aviso de sesion vencida (se lee una sola vez al montar el login).
|
||||||
|
const [sessionExpired] = useState(() => readSessionExpiredFlag());
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -26,8 +31,13 @@ export default function LoginPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
// Vuelve a donde estaba: ruta guardada al vencer la sesion (solo si es el mismo usuario) o
|
||||||
navigate('/', { replace: true });
|
// la ruta protegida que intentaba abrir; si no, al inicio.
|
||||||
|
const { returnTo } = await login(email, password);
|
||||||
|
// Tras un vencimiento solo vale returnTo (verificado contra el usuario que entra): la ruta
|
||||||
|
// "from" de la redireccion podria ser la de OTRO usuario que uso este navegador.
|
||||||
|
const from = sessionExpired ? null : location.state?.from;
|
||||||
|
navigate(returnTo || (typeof from === 'string' && from.startsWith('/') ? from : '/'), { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || t('login_error'));
|
setError(err.message || t('login_error'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,6 +52,9 @@ export default function LoginPage() {
|
|||||||
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
||||||
</div>
|
</div>
|
||||||
<div className="login-title">{t('login_title')}</div>
|
<div className="login-title">{t('login_title')}</div>
|
||||||
|
{sessionExpired && !error && (
|
||||||
|
<InfoBar tone="warning" dismissible={false}>{t('session_expired_login_msg')}</InfoBar>
|
||||||
|
)}
|
||||||
{error && <div className="login-error">{error}</div>}
|
{error && <div className="login-error">{error}</div>}
|
||||||
<Field label={t('login_email')}>
|
<Field label={t('login_email')}>
|
||||||
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { Button } from '../../components/Button.jsx';
|
|||||||
import { Field } from '../../components/Field.jsx';
|
import { Field } from '../../components/Field.jsx';
|
||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
@@ -25,7 +28,22 @@ export default function NotasPage() {
|
|||||||
|
|
||||||
const [selected, setSelected] = useState(null); // nota abierta en el popup (null = cerrado)
|
const [selected, setSelected] = useState(null); // nota abierta en el popup (null = cerrado)
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [content, setContent] = useState('');
|
// Borrador de la nota (nueva o edicion de una propia; las compartidas conmigo son solo lectura).
|
||||||
|
const editable = creating || !!selected?.is_owner;
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: creating ? 'note-new' : 'note-edit', entityId: creating ? null : selected?.id,
|
||||||
|
initial: { content: creating ? '' : (selected?.content || ''), editVersion: selected?.edit_version },
|
||||||
|
label: creating ? t('notes_new') : `${t('notes_title')}: ${(selected?.content || '').slice(0, 40)}`,
|
||||||
|
enabled: editable,
|
||||||
|
});
|
||||||
|
const content = draft.values.content;
|
||||||
|
const setContent = (v) => draft.setField('content', v);
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
useReopenDraft({
|
||||||
|
newFormId: 'note-new', editFormId: 'note-edit', items: notes?.filter((n) => n.is_owner),
|
||||||
|
openNew: () => openCreate(), openEdit: (n) => openNote(n),
|
||||||
|
});
|
||||||
const [shares, setShares] = useState([]);
|
const [shares, setShares] = useState([]);
|
||||||
const [sharingWith, setSharingWith] = useState('');
|
const [sharingWith, setSharingWith] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -33,7 +51,6 @@ export default function NotasPage() {
|
|||||||
function openCreate() {
|
function openCreate() {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setContent('');
|
|
||||||
setShares([]);
|
setShares([]);
|
||||||
setSharingWith('');
|
setSharingWith('');
|
||||||
}
|
}
|
||||||
@@ -41,7 +58,6 @@ export default function NotasPage() {
|
|||||||
async function openNote(note) {
|
async function openNote(note) {
|
||||||
setSelected(note);
|
setSelected(note);
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setContent(note.content);
|
|
||||||
setSharingWith('');
|
setSharingWith('');
|
||||||
setShares(note.is_owner ? await listNoteShares(note.id) : []);
|
setShares(note.is_owner ? await listNoteShares(note.id) : []);
|
||||||
}
|
}
|
||||||
@@ -53,15 +69,17 @@ export default function NotasPage() {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!content.trim()) return;
|
if (!content.trim()) return;
|
||||||
await runAction(async () => {
|
const run = () => runAction(async () => {
|
||||||
if (creating) {
|
if (creating) {
|
||||||
await createNote(content);
|
await createNote(content);
|
||||||
} else {
|
} else {
|
||||||
await updateNote(selected.id, content);
|
await updateNote(selected.id, content, draft.values.editVersion);
|
||||||
}
|
}
|
||||||
|
draft.clear();
|
||||||
closePopup();
|
closePopup();
|
||||||
await reload();
|
await reload();
|
||||||
});
|
});
|
||||||
|
if (creating) await run(); else await draft.guardSave(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
@@ -75,6 +93,7 @@ export default function NotasPage() {
|
|||||||
label: t('common_delete'), variant: 'danger',
|
label: t('common_delete'), variant: 'danger',
|
||||||
onClick: () => runAction(async () => {
|
onClick: () => runAction(async () => {
|
||||||
await deleteNote(selected.id);
|
await deleteNote(selected.id);
|
||||||
|
draft.discard();
|
||||||
closePopup();
|
closePopup();
|
||||||
await reload();
|
await reload();
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
@@ -129,7 +148,7 @@ export default function NotasPage() {
|
|||||||
<div className="notes-grid">
|
<div className="notes-grid">
|
||||||
<button type="button" className="notes-card notes-card-new" onClick={openCreate}>
|
<button type="button" className="notes-card notes-card-new" onClick={openCreate}>
|
||||||
<Plus size={28} />
|
<Plus size={28} />
|
||||||
<span>{t('notes_new')}</span>
|
<span>{t('notes_new')}{hasDraftFor(drafts, 'note-new', null) && <DraftMark />}</span>
|
||||||
</button>
|
</button>
|
||||||
{displayedOwnNotes.map((note) => (
|
{displayedOwnNotes.map((note) => (
|
||||||
<div
|
<div
|
||||||
@@ -139,6 +158,7 @@ export default function NotasPage() {
|
|||||||
{...(dragEnabled ? getRowProps(note) : {})}
|
{...(dragEnabled ? getRowProps(note) : {})}
|
||||||
>
|
>
|
||||||
<div className="notes-card-corner">
|
<div className="notes-card-corner">
|
||||||
|
{hasDraftFor(drafts, 'note-edit', note.id) && <DraftMark />}
|
||||||
{note.share_count > 0 && (
|
{note.share_count > 0 && (
|
||||||
<span className="notes-card-corner-share" title={t('notes_share_title')}>
|
<span className="notes-card-corner-share" title={t('notes_share_title')}>
|
||||||
<Share2 size={13} strokeWidth={2.5} />
|
<Share2 size={13} strokeWidth={2.5} />
|
||||||
@@ -189,21 +209,23 @@ export default function NotasPage() {
|
|||||||
<DetailPopup
|
<DetailPopup
|
||||||
open={creating || !!selected}
|
open={creating || !!selected}
|
||||||
title={creating ? t('notes_new') : t('notes_title')}
|
title={creating ? t('notes_new') : t('notes_title')}
|
||||||
onClose={closePopup}
|
onClose={() => guardedClose(draft, closePopup)}
|
||||||
width={560}
|
width={560}
|
||||||
height={creating || selected?.is_owner ? 620 : 420}
|
height={creating || selected?.is_owner ? 620 : 420}
|
||||||
>
|
>
|
||||||
{creating || selected?.is_owner ? (
|
{creating || selected?.is_owner ? (
|
||||||
<>
|
<>
|
||||||
|
<DraftNotice draft={draft} />
|
||||||
<textarea
|
<textarea
|
||||||
className="textarea" rows={6} autoFocus
|
className={`textarea ${draft.isDirty ? 'input-dirty' : ''}`} rows={6} autoFocus
|
||||||
|
aria-label={draft.isDirty ? `${t('notes_title')} (${t('draft_field_modified')})` : t('notes_title')}
|
||||||
placeholder={t('notes_placeholder')}
|
placeholder={t('notes_placeholder')}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: creating ? 0 : 20 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: creating ? 0 : 20 }}>
|
||||||
<Button variant="accent" icon={Save} onClick={save} disabled={!content.trim()}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={save} disabled={!content.trim()}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" onClick={closePopup}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { draft.discard(); closePopup(); }}>{t('common_cancel')}</Button>
|
||||||
{!creating && <Button variant="danger" icon={Trash2} onClick={confirmDelete}>{t('common_delete')}</Button>}
|
{!creating && <Button variant="danger" icon={Trash2} onClick={confirmDelete}>{t('common_delete')}</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { Button } from '../../components/Button.jsx';
|
|||||||
import { Field } from '../../components/Field.jsx';
|
import { Field } from '../../components/Field.jsx';
|
||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||||
|
|
||||||
function flatten(nodes, acc = []) {
|
function flatten(nodes, acc = []) {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
@@ -23,32 +26,42 @@ export default function DepartmentsPanel() {
|
|||||||
const { data: tree, reload } = useAsync(listDepartments, []);
|
const { data: tree, reload } = useAsync(listDepartments, []);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [form, setForm] = useState({ name: '', parentId: '' });
|
const guardedClose = useGuardedClose();
|
||||||
|
// Borrador del departamento (nuevo o edicion).
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: selected ? 'department-edit' : 'department-new', entityId: selected?.id ?? null,
|
||||||
|
initial: selected ? { name: selected.name, parentId: selected.parent_id || '', editVersion: selected.edit_version } : { name: '', parentId: '' },
|
||||||
|
label: selected ? `${t('common_edit')}: ${selected.name}` : t('department_new'), enabled: editing,
|
||||||
|
});
|
||||||
|
const form = draft.values;
|
||||||
|
const setForm = draft.setValues;
|
||||||
|
|
||||||
const flatOptions = tree ? flatten(tree).map((d) => ({ value: d.id, label: d.name })) : [];
|
const flatOptions = tree ? flatten(tree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||||
|
const pendingNew = hasDraftFor(useUserDrafts(), 'department-new', null);
|
||||||
|
useReopenDraft({ newFormId: 'department-new', editFormId: 'department-edit', items: tree ? flatten(tree) : null, openNew: () => startNew(), openEdit: (n) => startEdit(n) });
|
||||||
|
|
||||||
function startNew() {
|
function startNew() {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setForm({ name: '', parentId: '' });
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(node) {
|
function startEdit(node) {
|
||||||
setSelected(node);
|
setSelected(node);
|
||||||
setForm({ name: node.name, parentId: node.parent_id || '' });
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.name.trim()) return;
|
if (!form.name.trim()) return;
|
||||||
await runAction(async () => {
|
const run = () => runAction(async () => {
|
||||||
const payload = { name: form.name.trim(), parentId: form.parentId || null };
|
const payload = { name: form.name.trim(), parentId: form.parentId || null, editVersion: form.editVersion };
|
||||||
if (selected) await updateDepartment(selected.id, payload);
|
if (selected) await updateDepartment(selected.id, payload);
|
||||||
else await createDepartment(payload);
|
else await createDepartment(payload);
|
||||||
|
draft.clear();
|
||||||
await reload();
|
await reload();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
});
|
});
|
||||||
|
if (selected) await draft.guardSave(run); else await run();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(node) {
|
function confirmDelete(node) {
|
||||||
@@ -62,6 +75,7 @@ export default function DepartmentsPanel() {
|
|||||||
label: t('common_delete'), variant: 'danger',
|
label: t('common_delete'), variant: 'danger',
|
||||||
onClick: () => runAction(async () => {
|
onClick: () => runAction(async () => {
|
||||||
await deleteDepartment(node.id);
|
await deleteDepartment(node.id);
|
||||||
|
draft.discard();
|
||||||
await reload();
|
await reload();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
@@ -78,16 +92,17 @@ export default function DepartmentsPanel() {
|
|||||||
<div className="panel-card">
|
<div className="panel-card">
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<h3 style={{ margin: 0 }}>{t('org_departments')}</h3>
|
<h3 style={{ margin: 0 }}>{t('org_departments')}</h3>
|
||||||
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}</Button>
|
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}{pendingNew && !editing && <DraftMark />}</Button>
|
||||||
</div>
|
</div>
|
||||||
<Treeview nodes={tree} onSelect={startEdit} selectedId={selected?.id} />
|
<Treeview nodes={tree} onSelect={startEdit} selectedId={selected?.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('department_new')} onClose={() => setEditing(false)} width={480} height={360}>
|
<DetailPopup open={editing} title={selected ? t('common_edit') : t('department_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={480} height={400}>
|
||||||
<Field label={t('common_name')}>
|
<DraftNotice draft={draft} />
|
||||||
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
||||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('department_parent')}>
|
<Field label={t('department_parent')} dirty={draft.isFieldDirty('parentId')}>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
options={[{ value: '', label: '—' }, ...flatOptions.filter((o) => o.value !== selected?.id)]}
|
options={[{ value: '', label: '—' }, ...flatOptions.filter((o) => o.value !== selected?.id)]}
|
||||||
value={form.parentId}
|
value={form.parentId}
|
||||||
@@ -96,7 +111,7 @@ export default function DepartmentsPanel() {
|
|||||||
</Field>
|
</Field>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||||
{selected && <Button variant="danger" icon={Trash2} onClick={() => confirmDelete(selected)}>{t('common_delete')}</Button>}
|
{selected && <Button variant="danger" icon={Trash2} onClick={() => confirmDelete(selected)}>{t('common_delete')}</Button>}
|
||||||
</div>
|
</div>
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { useState } from 'react';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { Tabs } from '../../components/Tabs.jsx';
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
|
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||||
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
||||||
import UsersPanel from './UsersPanel.jsx';
|
import UsersPanel from './UsersPanel.jsx';
|
||||||
|
|
||||||
export default function OrganizacionPage() {
|
export default function OrganizacionPage() {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const [tab, setTab] = useState('departments');
|
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const tab = searchParams.get('tab') === 'users' ? 'users' : 'departments';
|
||||||
|
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('nav_organization')}</h2>
|
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('nav_organization')}</h2>
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'departments', label: t('org_departments') },
|
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
|
||||||
{ key: 'users', label: t('org_users') },
|
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
|
||||||
]}
|
]}
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
|||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
|
|
||||||
function flatten(nodes, acc = []) {
|
function flatten(nodes, acc = []) {
|
||||||
@@ -35,39 +38,59 @@ export default function UsersPanel() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
|
||||||
const [approving, setApproving] = useState(null);
|
const [approving, setApproving] = useState(null);
|
||||||
const [approveForm, setApproveForm] = useState({ roleId: '', departmentId: '' });
|
const guardedClose = useGuardedClose();
|
||||||
|
// Borrador del usuario (nuevo o edicion). La contraseña nunca se guarda como borrador.
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: selected ? 'user-edit' : 'user-new', entityId: selected?.id ?? null,
|
||||||
|
initial: selected
|
||||||
|
? { name: selected.name, email: selected.email, roleId: selected.role_id, departmentId: selected.department_id || '', position: selected.position || '', password: '', editVersion: selected.edit_version }
|
||||||
|
: EMPTY_FORM,
|
||||||
|
label: selected ? `${t('common_edit')}: ${selected.name}` : t('user_new'), enabled: editing, exclude: ['password'],
|
||||||
|
});
|
||||||
|
const form = draft.values;
|
||||||
|
const setForm = draft.setValues;
|
||||||
|
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
|
||||||
|
const approveDraft = useDraftForm({
|
||||||
|
formId: 'user-approve', entityId: approving?.id,
|
||||||
|
initial: { roleId: approving?.role_id || collaboratorRole?.id || '', departmentId: '', editVersion: approving?.edit_version },
|
||||||
|
label: `${t('user_approve_title')}: ${approving?.name || ''}`, enabled: !!approving,
|
||||||
|
});
|
||||||
|
const approveForm = approveDraft.values;
|
||||||
|
const setApproveForm = approveDraft.setValues;
|
||||||
|
const pendingNew = hasDraftFor(useUserDrafts(), 'user-new', null);
|
||||||
|
useReopenDraft({ newFormId: canManage ? 'user-new' : null, editFormId: canManage ? 'user-edit' : null, items: users, openNew: () => startNew(), openEdit: (u) => startEdit(u) });
|
||||||
|
useReopenDraft({ editFormId: canManage ? 'user-approve' : null, items: users, openEdit: (u) => setApproving(u) });
|
||||||
|
|
||||||
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||||
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
|
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
|
||||||
|
|
||||||
function startNew() {
|
function startNew() {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setForm(EMPTY_FORM);
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(user) {
|
function startEdit(user) {
|
||||||
if (!canManage) return;
|
if (!canManage) return;
|
||||||
setSelected(user);
|
setSelected(user);
|
||||||
setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' });
|
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.name.trim() || !form.email.trim() || !form.roleId) return;
|
if (!form.name.trim() || !form.email.trim() || !form.roleId) return;
|
||||||
await runAction(async () => {
|
if (!selected && !form.password) return;
|
||||||
|
const run = () => runAction(async () => {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null });
|
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion });
|
||||||
} else {
|
} else {
|
||||||
if (!form.password) return;
|
|
||||||
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password });
|
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password });
|
||||||
}
|
}
|
||||||
|
draft.clear();
|
||||||
await reload();
|
await reload();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
});
|
});
|
||||||
|
if (selected) await draft.guardSave(run); else await run();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(user) {
|
async function toggleActive(user) {
|
||||||
@@ -78,18 +101,17 @@ export default function UsersPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startApprove(user) {
|
function startApprove(user) {
|
||||||
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
|
|
||||||
setApproving(user);
|
setApproving(user);
|
||||||
setApproveForm({ roleId: user.role_id || collaboratorRole?.id || '', departmentId: '' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmApprove() {
|
async function confirmApprove() {
|
||||||
await runAction(async () => {
|
await approveDraft.guardSave(() => runAction(async () => {
|
||||||
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null });
|
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null, editVersion: approveForm.editVersion });
|
||||||
|
approveDraft.clear();
|
||||||
setApproving(null);
|
setApproving(null);
|
||||||
await reload();
|
await reload();
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resendEmail(user) {
|
function resendEmail(user) {
|
||||||
@@ -129,7 +151,7 @@ export default function UsersPanel() {
|
|||||||
<div className="toolbar-left">
|
<div className="toolbar-left">
|
||||||
<SearchInput value={search} onChange={setSearch} />
|
<SearchInput value={search} onChange={setSearch} />
|
||||||
</div>
|
</div>
|
||||||
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>}
|
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}{pendingNew && !editing && <DraftMark />}</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ListView
|
<ListView
|
||||||
@@ -164,51 +186,53 @@ export default function UsersPanel() {
|
|||||||
} : undefined}
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => setEditing(false)} width={560} height={440}>
|
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
||||||
|
<DraftNotice draft={draft} />
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('common_name')}>
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
||||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('user_email')}>
|
<Field label={t('user_email')} dirty={draft.isFieldDirty('email')}>
|
||||||
<input className="input" type="email" value={form.email} disabled={!!selected} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
|
<input className="input" type="email" value={form.email} disabled={!!selected} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('user_role')}>
|
<Field label={t('user_role')} dirty={draft.isFieldDirty('roleId')}>
|
||||||
<ComboBox options={roleOptions} value={form.roleId} onChange={(v) => setForm((f) => ({ ...f, roleId: v }))} />
|
<ComboBox options={roleOptions} value={form.roleId} onChange={(v) => setForm((f) => ({ ...f, roleId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_department')}>
|
<Field label={t('common_department')} dirty={draft.isFieldDirty('departmentId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm((f) => ({ ...f, departmentId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm((f) => ({ ...f, departmentId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('user_position')}>
|
<Field label={t('user_position')} dirty={draft.isFieldDirty('position')}>
|
||||||
<input className="input" value={form.position} onChange={(e) => setForm((f) => ({ ...f, position: e.target.value }))} />
|
<input className="input" value={form.position} onChange={(e) => setForm((f) => ({ ...f, position: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
{!selected && (
|
{!selected && (
|
||||||
<Field label={t('user_password')}>
|
<Field label={t('user_password')} dirty={draft.isFieldDirty('password')}>
|
||||||
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
|
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|
||||||
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => setApproving(null)} width={420} height={300}>
|
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => guardedClose(approveDraft, () => setApproving(null))} width={420} height={340}>
|
||||||
|
<DraftNotice draft={approveDraft} />
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('user_role')}>
|
<Field label={t('user_role')} dirty={approveDraft.isFieldDirty('roleId')}>
|
||||||
<ComboBox options={roleOptions} value={approveForm.roleId} onChange={(v) => setApproveForm((f) => ({ ...f, roleId: v }))} />
|
<ComboBox options={roleOptions} value={approveForm.roleId} onChange={(v) => setApproveForm((f) => ({ ...f, roleId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_department')}>
|
<Field label={t('common_department')} dirty={approveDraft.isFieldDirty('departmentId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={approveForm.departmentId} onChange={(v) => setApproveForm((f) => ({ ...f, departmentId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={approveForm.departmentId} onChange={(v) => setApproveForm((f) => ({ ...f, departmentId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button variant="accent" icon={Check} onClick={confirmApprove}>{t('user_approve')}</Button>
|
<Button variant="accent" icon={Check} onClick={confirmApprove}>{t('user_approve')}</Button>
|
||||||
<Button variant="standard" onClick={() => setApproving(null)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { approveDraft.discard(); setApproving(null); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import { Tabs } from '../../components/Tabs.jsx';
|
|||||||
import { CommentsPanel } from '../../components/CommentsPanel.jsx';
|
import { CommentsPanel } from '../../components/CommentsPanel.jsx';
|
||||||
import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx';
|
import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||||
|
import { getActivity } from '../../services/activities.js';
|
||||||
|
|
||||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` }));
|
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` }));
|
||||||
|
|
||||||
@@ -26,11 +30,11 @@ function toForm(a) {
|
|||||||
name: a.name, description: a.description || '', responsibleUserId: a.responsible_user_id || '',
|
name: a.name, description: a.description || '', responsibleUserId: a.responsible_user_id || '',
|
||||||
priorityId: a.priority_id, statusId: a.status_id, startDate: a.start_date || '', dueDate: a.due_date || '',
|
priorityId: a.priority_id, statusId: a.status_id, startDate: a.start_date || '', dueDate: a.due_date || '',
|
||||||
actualEndDate: a.actual_end_date || '', isMilestone: !!a.is_milestone, estimatedHours: a.estimated_hours || '',
|
actualEndDate: a.actual_end_date || '', isMilestone: !!a.is_milestone, estimatedHours: a.estimated_hours || '',
|
||||||
progressPct: a.progress_pct,
|
progressPct: a.progress_pct, editVersion: a.edit_version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, initialTab }) {
|
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, tab = 'detail', onTabChange }) {
|
||||||
const { t, pushToast } = useUi();
|
const { t, pushToast } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||||
@@ -42,22 +46,75 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
await reloadTasks();
|
await reloadTasks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const [tab, setTab] = useState(initialTab || 'detail');
|
// La pestaña la controla el padre (vive en la URL: ?activityTab=).
|
||||||
|
const setTab = onTabChange;
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
// Se recargan al cambiar de pestaña, y ademas CommentsPanel/AttachmentsPanel avisan via
|
// Se recargan al cambiar de pestaña, y ademas CommentsPanel/AttachmentsPanel avisan via
|
||||||
// onChanged cuando se agrega/borra algo estando ya parado en esa pestaña (si no, el badge se
|
// onChanged cuando se agrega/borra algo estando ya parado en esa pestaña (si no, el badge se
|
||||||
// quedaba pegado en el numero viejo hasta cambiar de pestaña y volver).
|
// quedaba pegado en el numero viejo hasta cambiar de pestaña y volver).
|
||||||
const { data: comments, reload: reloadComments } = useAsync(() => listComments('activity', activity.id), [activity.id, tab]);
|
const { data: comments, reload: reloadComments } = useAsync(() => listComments('activity', activity.id), [activity.id, tab]);
|
||||||
const { data: attachments, reload: reloadAttachments } = useAsync(() => listAttachments('activity', activity.id), [activity.id, tab]);
|
const { data: attachments, reload: reloadAttachments } = useAsync(() => listAttachments('activity', activity.id), [activity.id, tab]);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [form, setForm] = useState(() => toForm(activity));
|
|
||||||
const [newTask, setNewTask] = useState('');
|
|
||||||
const [editingTaskId, setEditingTaskId] = useState(null);
|
const [editingTaskId, setEditingTaskId] = useState(null);
|
||||||
const [editingTaskName, setEditingTaskName] = useState('');
|
|
||||||
const [addingDep, setAddingDep] = useState('');
|
const [addingDep, setAddingDep] = useState('');
|
||||||
const [promoting, setPromoting] = useState(null);
|
const [promoting, setPromoting] = useState(null);
|
||||||
const [promoteForm, setPromoteForm] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]);
|
// Borradores: edicion de la actividad, nueva tarea, edicion en linea de una tarea y
|
||||||
|
// "Convertir en recurrente". Todos sobreviven a cambiar de pestaña/menu, F5 y vencimiento.
|
||||||
|
// Cada borrador se activa solo mientras su pestaña esta visible: uno oculto queda "pendiente"
|
||||||
|
// (marcado en su pestaña), no "en edicion".
|
||||||
|
const draft = useDraftForm({ formId: 'activity-info', entityId: activity.id, initial: toForm(activity), label: `${activity.code} — ${t('tab_info')}`, enabled: tab === 'detail', onDiscard: () => setEditing(false) });
|
||||||
|
const form = draft.values;
|
||||||
|
const setForm = draft.setValues;
|
||||||
|
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
|
||||||
|
|
||||||
|
const newTaskDraft = useDraftForm({ formId: 'task-new', entityId: activity.id, initial: { name: '' }, label: `${activity.code} — ${t('task_new')}`, enabled: tab === 'tasks' });
|
||||||
|
const newTask = newTaskDraft.values.name;
|
||||||
|
const setNewTask = (v) => newTaskDraft.setField('name', v);
|
||||||
|
|
||||||
|
const editingTask = (tasks || []).find((task) => task.id === editingTaskId);
|
||||||
|
const taskEditDraft = useDraftForm({
|
||||||
|
formId: 'task-edit', entityId: editingTaskId, initial: { name: editingTask?.name || '', editVersion: editingTask?.edit_version },
|
||||||
|
label: `${activity.code} — ${editingTask?.name || ''}`, enabled: !!editingTask && tab === 'tasks', onDiscard: () => setEditingTaskId(null),
|
||||||
|
});
|
||||||
|
const editingTaskName = taskEditDraft.values.name;
|
||||||
|
const setEditingTaskName = (v) => taskEditDraft.setField('name', v);
|
||||||
|
|
||||||
|
const promoteDraft = useDraftForm({
|
||||||
|
formId: 'task-promote', entityId: promoting?.id,
|
||||||
|
initial: promoting ? {
|
||||||
|
name: promoting.name, responsibleUserId: promoting.responsible_user_id || '',
|
||||||
|
intervalUnit: 'months', intervalValue: 1, nextDueDate: promoting.due_date || '', endDate: '',
|
||||||
|
reminderDaysBefore: 7, reminderHour: 9,
|
||||||
|
} : {},
|
||||||
|
label: `${promoting?.name || ''} — ${t('recurring_task_promote_title')}`, enabled: !!promoting, onDiscard: () => setPromoting(null),
|
||||||
|
});
|
||||||
|
const promoteForm = promoting ? promoteDraft.values : null;
|
||||||
|
const setPromoteForm = promoteDraft.setValues;
|
||||||
|
|
||||||
|
// Al volver con borradores de edicion en linea / "Convertir en recurrente" de alguna tarea de
|
||||||
|
// esta actividad, se reabren sobre su tarea.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tasks) return;
|
||||||
|
const byId = (formId) => {
|
||||||
|
const d = drafts.find((x) => x.formId === formId && tasks.some((task) => String(task.id) === String(x.entityId)));
|
||||||
|
return d ? tasks.find((task) => String(task.id) === String(d.entityId)) : null;
|
||||||
|
};
|
||||||
|
const toEdit = byId('task-edit');
|
||||||
|
if (toEdit && !editingTaskId) setEditingTaskId(toEdit.id);
|
||||||
|
const toPromote = byId('task-promote');
|
||||||
|
if (toPromote && !promoting) setPromoting(toPromote);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
// Pestañas con borradores pendientes de esta actividad (por la URL que guarda cada borrador:
|
||||||
|
// ?activity=<id>&activityTab=<pestaña>).
|
||||||
|
const dirtyTab = (key) => drafts.some((d) => {
|
||||||
|
if (typeof d.path !== 'string') return false;
|
||||||
|
const q = new URLSearchParams(d.path.split('?')[1] || '');
|
||||||
|
return q.get('activity') === String(activity.id) && (q.get('activityTab') || 'detail') === key;
|
||||||
|
});
|
||||||
|
|
||||||
const hasTasks = tasks && tasks.length > 0;
|
const hasTasks = tasks && tasks.length > 0;
|
||||||
const totalTasksCount = (tasks || []).length;
|
const totalTasksCount = (tasks || []).length;
|
||||||
@@ -72,19 +129,21 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
.map((a) => ({ value: a.id, label: `${a.code} — ${a.name}` }));
|
.map((a) => ({ value: a.id, label: `${a.code} — ${a.name}` }));
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
await runAction(async () => {
|
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
|
||||||
|
await draft.guardSave(() => runAction(async () => {
|
||||||
await updateActivity(activity.id, { ...form, responsibleUserId: form.responsibleUserId || null, estimatedHours: form.estimatedHours || null });
|
await updateActivity(activity.id, { ...form, responsibleUserId: form.responsibleUserId || null, estimatedHours: form.estimatedHours || null });
|
||||||
|
draft.clear();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
onChanged();
|
onChanged();
|
||||||
});
|
}), { fetchCurrent: async () => toForm(await getActivity(activity.id)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addTaskItem() {
|
async function addTaskItem() {
|
||||||
if (!newTask.trim()) return;
|
if (!newTask.trim()) return;
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await createTask(activity.id, { name: newTask.trim() });
|
await createTask(activity.id, { name: newTask.trim() });
|
||||||
setNewTask('');
|
newTaskDraft.clear();
|
||||||
await reloadTasks();
|
await reloadTasks();
|
||||||
onChanged();
|
onChanged();
|
||||||
});
|
});
|
||||||
@@ -100,31 +159,26 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
|
|
||||||
function startEditTask(task) {
|
function startEditTask(task) {
|
||||||
setEditingTaskId(task.id);
|
setEditingTaskId(task.id);
|
||||||
setEditingTaskName(task.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEditTask() {
|
function cancelEditTask() {
|
||||||
|
taskEditDraft.discard();
|
||||||
setEditingTaskId(null);
|
setEditingTaskId(null);
|
||||||
setEditingTaskName('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEditTask() {
|
async function saveEditTask() {
|
||||||
if (!editingTaskName.trim()) return;
|
if (!editingTaskName.trim()) return;
|
||||||
await runAction(async () => {
|
await taskEditDraft.guardSave(() => runAction(async () => {
|
||||||
await updateTask(editingTaskId, { name: editingTaskName.trim() });
|
await updateTask(editingTaskId, { name: editingTaskName.trim(), editVersion: taskEditDraft.values.editVersion });
|
||||||
cancelEditTask();
|
taskEditDraft.clear();
|
||||||
|
setEditingTaskId(null);
|
||||||
await reloadTasks();
|
await reloadTasks();
|
||||||
onChanged();
|
onChanged();
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPromote(task) {
|
function startPromote(task) {
|
||||||
setPromoting(task);
|
setPromoting(task);
|
||||||
setPromoteForm({
|
|
||||||
name: task.name, responsibleUserId: task.responsible_user_id || '',
|
|
||||||
intervalUnit: 'months', intervalValue: 1, nextDueDate: task.due_date || '', endDate: '',
|
|
||||||
reminderDaysBefore: 7, reminderHour: 9,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmPromote() {
|
async function confirmPromote() {
|
||||||
@@ -133,6 +187,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
await promoteTaskToRecurring(promoting.id, {
|
await promoteTaskToRecurring(promoting.id, {
|
||||||
...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, endDate: promoteForm.endDate || null,
|
...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, endDate: promoteForm.endDate || null,
|
||||||
});
|
});
|
||||||
|
promoteDraft.clear();
|
||||||
setPromoting(null);
|
setPromoting(null);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
});
|
});
|
||||||
@@ -151,11 +206,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
<div>
|
<div>
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'detail', label: t('tab_info') },
|
{ key: 'detail', label: t('tab_info'), dirty: dirtyTab('detail') },
|
||||||
{ key: 'tasks', label: t('tasks_title'), count: tasksTabCount },
|
{ key: 'tasks', label: t('tasks_title'), count: tasksTabCount, dirty: dirtyTab('tasks') },
|
||||||
{ key: 'dependencies', label: t('activity_dependencies'), count: (activity.dependencies || []).length },
|
{ key: 'dependencies', label: t('activity_dependencies'), count: (activity.dependencies || []).length },
|
||||||
{ key: 'comments', label: t('tab_comments'), count: (comments || []).length },
|
{ key: 'comments', label: t('tab_comments'), count: (comments || []).length, dirty: dirtyTab('comments') },
|
||||||
{ key: 'documents', label: t('tab_documents'), count: (attachments || []).length },
|
{ key: 'documents', label: t('tab_documents'), count: (attachments || []).length, dirty: dirtyTab('documents') },
|
||||||
]}
|
]}
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
@@ -167,7 +222,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" icon={X} onClick={() => { setForm(toForm(activity)); setEditing(false); }}>{t('common_cancel')}</Button>
|
<Button variant="standard" icon={X} onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
||||||
@@ -193,46 +248,47 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<Field label={t('common_name')}>
|
<DraftNotice draft={draft} />
|
||||||
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
||||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_description')}>
|
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
|
||||||
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('common_responsible')}>
|
<Field label={t('common_responsible')} dirty={draft.isFieldDirty('responsibleUserId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_priority')}>
|
<Field label={t('common_priority')} dirty={draft.isFieldDirty('priorityId')}>
|
||||||
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_status')}>
|
<Field label={t('common_status')} dirty={draft.isFieldDirty('statusId')}>
|
||||||
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('activity_start_date')}>
|
<Field label={t('activity_start_date')} dirty={draft.isFieldDirty('startDate')}>
|
||||||
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('activity_due_date')}>
|
<Field label={t('activity_due_date')} dirty={draft.isFieldDirty('dueDate')}>
|
||||||
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
|
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('activity_actual_end_date')}>
|
<Field label={t('activity_actual_end_date')} dirty={draft.isFieldDirty('actualEndDate')}>
|
||||||
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
|
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('activity_estimated_hours')}>
|
<Field label={t('activity_estimated_hours')} dirty={draft.isFieldDirty('estimatedHours')}>
|
||||||
<input className="input" type="number" value={form.estimatedHours} onChange={(e) => setForm((f) => ({ ...f, estimatedHours: e.target.value }))} />
|
<input className="input" type="number" value={form.estimatedHours} onChange={(e) => setForm((f) => ({ ...f, estimatedHours: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('activity_milestone')}>
|
<Field label={t('activity_milestone')} dirty={draft.isFieldDirty('isMilestone')}>
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
|
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
|
||||||
{t('activity_milestone')}
|
{t('activity_milestone')}
|
||||||
</label>
|
</label>
|
||||||
</Field>
|
</Field>
|
||||||
{!hasTasks && (
|
{!hasTasks && (
|
||||||
<Field label={t('activity_progress')}>
|
<Field label={t('activity_progress')} dirty={draft.isFieldDirty('progressPct')}>
|
||||||
<input className="input" type="number" min={0} max={100} value={form.progressPct} onChange={(e) => setForm((f) => ({ ...f, progressPct: e.target.value }))} />
|
<input className="input" type="number" min={0} max={100} value={form.progressPct} onChange={(e) => setForm((f) => ({ ...f, progressPct: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
@@ -254,7 +310,8 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
{editingTaskId === task.id ? (
|
{editingTaskId === task.id ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
className="input" style={{ flex: 1 }} value={editingTaskName} autoFocus
|
className={`input ${taskEditDraft.isDirty ? 'input-dirty' : ''}`} style={{ flex: 1 }} value={editingTaskName} autoFocus
|
||||||
|
aria-label={taskEditDraft.isDirty ? `${t('common_edit')} (${t('draft_field_modified')})` : t('common_edit')}
|
||||||
onChange={(e) => setEditingTaskName(e.target.value)}
|
onChange={(e) => setEditingTaskName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') saveEditTask(); if (e.key === 'Escape') cancelEditTask(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') saveEditTask(); if (e.key === 'Escape') cancelEditTask(); }}
|
||||||
/>
|
/>
|
||||||
@@ -277,7 +334,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
))}
|
))}
|
||||||
{orderedTasks.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
{orderedTasks.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
<input className="input" value={newTask} placeholder={t('task_new')} onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()} />
|
<input
|
||||||
|
className={`input ${newTaskDraft.isDirty ? 'input-dirty' : ''}`} value={newTask} placeholder={t('task_new')}
|
||||||
|
aria-label={newTaskDraft.isDirty ? `${t('task_new')} (${t('draft_field_modified')})` : t('task_new')}
|
||||||
|
onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()}
|
||||||
|
/>
|
||||||
<Button variant="standard" icon={Plus} onClick={addTaskItem}>{t('common_new')}</Button>
|
<Button variant="standard" icon={Plus} onClick={addTaskItem}>{t('common_new')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,34 +373,35 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
<DetailPopup
|
<DetailPopup
|
||||||
open={!!promoting}
|
open={!!promoting}
|
||||||
title={t('recurring_task_promote_title')}
|
title={t('recurring_task_promote_title')}
|
||||||
onClose={() => setPromoting(null)}
|
onClose={() => guardedClose(promoteDraft, () => setPromoting(null))}
|
||||||
width={560}
|
width={560}
|
||||||
height={600}
|
height={640}
|
||||||
>
|
>
|
||||||
{promoteForm && (
|
{promoteForm && (
|
||||||
<>
|
<>
|
||||||
|
<DraftNotice draft={promoteDraft} />
|
||||||
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>{t('recurring_task_promote_hint')}</div>
|
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>{t('recurring_task_promote_hint')}</div>
|
||||||
<Field label={t('common_name')}>
|
<Field label={t('common_name')} dirty={promoteDraft.isFieldDirty('name')}>
|
||||||
<input className="input" value={promoteForm.name} onChange={(e) => setPromoteForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
<input className="input" value={promoteForm.name} onChange={(e) => setPromoteForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||||
</Field>
|
</Field>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('common_responsible')}>
|
<Field label={t('common_responsible')} dirty={promoteDraft.isFieldDirty('responsibleUserId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={promoteForm.responsibleUserId} onChange={(v) => setPromoteForm((f) => ({ ...f, responsibleUserId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={promoteForm.responsibleUserId} onChange={(v) => setPromoteForm((f) => ({ ...f, responsibleUserId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('recurring_task_next_due')}>
|
<Field label={t('recurring_task_next_due')} dirty={promoteDraft.isFieldDirty('nextDueDate')}>
|
||||||
<input className="input" type="date" value={promoteForm.nextDueDate} onChange={(e) => setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
|
<input className="input" type="date" value={promoteForm.nextDueDate} onChange={(e) => setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')}>
|
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')} dirty={promoteDraft.isFieldDirty('endDate')}>
|
||||||
<input className="input" type="date" value={promoteForm.endDate} onChange={(e) => setPromoteForm((f) => ({ ...f, endDate: e.target.value }))} />
|
<input className="input" type="date" value={promoteForm.endDate} onChange={(e) => setPromoteForm((f) => ({ ...f, endDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('recurring_task_every')}>
|
<Field label={t('recurring_task_every')} dirty={promoteDraft.isFieldDirty('intervalValue')}>
|
||||||
<input className="input" type="number" min={1} value={promoteForm.intervalValue} onChange={(e) => setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
|
<input className="input" type="number" min={1} value={promoteForm.intervalValue} onChange={(e) => setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('recurring_task_interval_unit')}>
|
<Field label={t('recurring_task_interval_unit')} dirty={promoteDraft.isFieldDirty('intervalUnit')}>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
options={[
|
options={[
|
||||||
{ value: 'days', label: t('recurring_task_unit_days') },
|
{ value: 'days', label: t('recurring_task_unit_days') },
|
||||||
@@ -352,16 +414,16 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('recurring_task_reminder_days')}>
|
<Field label={t('recurring_task_reminder_days')} dirty={promoteDraft.isFieldDirty('reminderDaysBefore')}>
|
||||||
<input className="input" type="number" min={0} value={promoteForm.reminderDaysBefore} onChange={(e) => setPromoteForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
|
<input className="input" type="number" min={0} value={promoteForm.reminderDaysBefore} onChange={(e) => setPromoteForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('recurring_task_reminder_hour')}>
|
<Field label={t('recurring_task_reminder_hour')} dirty={promoteDraft.isFieldDirty('reminderHour')}>
|
||||||
<ComboBox options={HOUR_OPTIONS} value={promoteForm.reminderHour} onChange={(v) => setPromoteForm((f) => ({ ...f, reminderHour: Number(v) }))} />
|
<ComboBox options={HOUR_OPTIONS} value={promoteForm.reminderHour} onChange={(v) => setPromoteForm((f) => ({ ...f, reminderHour: Number(v) }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
<Button variant="accent" icon={Repeat} onClick={confirmPromote}>{t('recurring_task_promote')}</Button>
|
<Button variant="accent" icon={Repeat} onClick={confirmPromote}>{t('recurring_task_promote')}</Button>
|
||||||
<Button variant="standard" onClick={() => setPromoting(null)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { promoteDraft.discard(); setPromoting(null); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw } from 'lucide-react';
|
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
|
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
|
||||||
import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject, listActivitiesTrash, restoreActivity } from '../../services/activities.js';
|
import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject, listActivitiesTrash, restoreActivity } from '../../services/activities.js';
|
||||||
import { listProjects } from '../../services/projects.js';
|
import { listProjectOptions } from '../../services/projects.js';
|
||||||
import { search as searchApi } from '../../services/search.js';
|
import { search as searchApi } from '../../services/search.js';
|
||||||
import { listCatalog } from '../../services/catalogs.js';
|
import { listCatalog } from '../../services/catalogs.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
@@ -20,10 +20,13 @@ import { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge
|
|||||||
import { dueStatus } from '../../utils/dueStatus.js';
|
import { dueStatus } from '../../utils/dueStatus.js';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
|
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
|
||||||
|
|
||||||
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
|
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
|
||||||
|
|
||||||
export default function ProjectActivitiesTab({ project, projectId, openActivityId, openActivityTab }) {
|
export default function ProjectActivitiesTab({ project, projectId }) {
|
||||||
const { t, pushToast, openDialog } = useUi();
|
const { t, pushToast, openDialog } = useUi();
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -45,17 +48,59 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||||
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
||||||
const { data: users } = useAsync(listUsers, []);
|
const { data: users } = useAsync(listUsers, []);
|
||||||
const { data: allProjects } = useAsync(() => listProjects(), []);
|
const { data: allProjects } = useAsync(listProjectOptions, []);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [selectedInitialTab, setSelectedInitialTab] = useState(undefined);
|
|
||||||
const [moving, setMoving] = useState(null);
|
const [moving, setMoving] = useState(null);
|
||||||
const [targetProjectId, setTargetProjectId] = useState('');
|
const [targetProjectId, setTargetProjectId] = useState('');
|
||||||
const [converting, setConverting] = useState(null);
|
const [converting, setConverting] = useState(null);
|
||||||
const [convertForm, setConvertForm] = useState({ projectName: '', taskActivityName: '' });
|
const drafts = useUserDrafts();
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
|
||||||
|
// "Nueva actividad" con borrador (por proyecto); se reabre al volver si quedo a medio llenar.
|
||||||
|
const draft = useDraftForm({ formId: 'activity-new', entityId: projectId, initial: EMPTY_FORM, label: `${project.code} — ${t('activity_new')}`, enabled: creating });
|
||||||
|
const form = draft.values;
|
||||||
|
const setForm = draft.setValues;
|
||||||
|
const pendingNew = hasDraftFor(drafts, 'activity-new', projectId);
|
||||||
|
useEffect(() => { if (pendingNew) setCreating(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// "Convertir en proyecto" con borrador (por actividad).
|
||||||
|
const convertDraft = useDraftForm({
|
||||||
|
formId: 'activity-convert', entityId: converting?.id,
|
||||||
|
initial: { projectName: converting?.name || '', taskActivityName: converting?.name || '' },
|
||||||
|
label: `${converting?.code || ''} — ${t('activity_convert_title')}`, enabled: !!converting,
|
||||||
|
});
|
||||||
|
const convertForm = convertDraft.values;
|
||||||
|
const setConvertForm = convertDraft.setValues;
|
||||||
|
|
||||||
|
// Actividad abierta y su pestaña viven en la URL (?activity=&activityTab=): F5, el retorno tras
|
||||||
|
// el login y el aviso de cambios sin guardar (al cerrar el popup o cambiar de pestaña) usan eso.
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const selectedId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
|
||||||
|
const activityTab = searchParams.get('activityTab') || 'detail';
|
||||||
|
const setActivityParams = (id, tab) => setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (id) {
|
||||||
|
next.set('activity', id);
|
||||||
|
if (tab && tab !== 'detail') next.set('activityTab', tab); else next.delete('activityTab');
|
||||||
|
} else {
|
||||||
|
next.delete('activity');
|
||||||
|
next.delete('activityTab');
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) { setSelected(null); return undefined; }
|
||||||
|
let alive = true;
|
||||||
|
// Si la actividad ya no existe o no hay acceso, no se abre (ni se restauran sus borradores).
|
||||||
|
getActivity(selectedId)
|
||||||
|
.then((a) => { if (alive) setSelected(a); })
|
||||||
|
.catch(() => { if (alive) setActivityParams(null); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
// Busca tambien tareas de esta actividad (no solo actividades, que ya filtra la tabla mas abajo
|
// Busca tambien tareas de esta actividad (no solo actividades, que ya filtra la tabla mas abajo
|
||||||
// via getSearchHaystack) — resultados con su ruta "Actividad - Tarea" en un panel flotante.
|
// via getSearchHaystack) — resultados con su ruta "Actividad - Tarea" en un panel flotante.
|
||||||
@@ -78,14 +123,14 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
})),
|
})),
|
||||||
] : null;
|
] : null;
|
||||||
|
|
||||||
// Llegada desde el buscador global de Proyectos: abre directo la actividad indicada (una sola vez).
|
// Al volver con un borrador de "Convertir en proyecto", se reabre sobre su actividad.
|
||||||
const openedFromDeepLink = useRef(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openedFromDeepLink.current || !openActivityId || !activities) return;
|
if (!activities || converting) return;
|
||||||
openedFromDeepLink.current = true;
|
const pending = drafts.find((d) => d.formId === 'activity-convert');
|
||||||
openActivity({ id: openActivityId }, openActivityTab);
|
const row = pending && activities.find((a) => String(a.id) === String(pending.entityId));
|
||||||
|
if (row) setConverting(row);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [openActivityId, activities]);
|
}, [activities]);
|
||||||
|
|
||||||
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||||
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
||||||
@@ -98,17 +143,15 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
|
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await createActivity(projectId, { ...form, responsibleUserId: form.responsibleUserId || null });
|
await createActivity(projectId, { ...form, responsibleUserId: form.responsibleUserId || null });
|
||||||
|
draft.clear();
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setForm(EMPTY_FORM);
|
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
await reload();
|
await reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openActivity(row, initialTab) {
|
function openActivity(row, initialTab) {
|
||||||
const full = await getActivity(row.id);
|
setActivityParams(row.id, initialTab);
|
||||||
setSelected(full);
|
|
||||||
setSelectedInitialTab(initialTab);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
@@ -151,7 +194,6 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
|
|
||||||
function startConvert(row) {
|
function startConvert(row) {
|
||||||
setConverting(row);
|
setConverting(row);
|
||||||
setConvertForm({ projectName: row.name, taskActivityName: row.name });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmConvert() {
|
function confirmConvert() {
|
||||||
@@ -171,6 +213,7 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
label: t('common_accept'), variant: 'accent',
|
label: t('common_accept'), variant: 'accent',
|
||||||
onClick: () => runAction(async () => {
|
onClick: () => runAction(async () => {
|
||||||
const result = await convertActivityToProject(converting.id, projectName.trim(), taskActivityName.trim());
|
const result = await convertActivityToProject(converting.id, projectName.trim(), taskActivityName.trim());
|
||||||
|
convertDraft.clear();
|
||||||
setConverting(null);
|
setConverting(null);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
navigate(`/proyectos/${result.project.id}`);
|
navigate(`/proyectos/${result.project.id}`);
|
||||||
@@ -229,7 +272,7 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
{project?.isOwner && (
|
{project?.isOwner && (
|
||||||
<Button variant="standard" icon={Archive} onClick={() => setTrashOpen(true)}>{t('activities_trash')}</Button>
|
<Button variant="standard" icon={Archive} onClick={() => setTrashOpen(true)}>{t('activities_trash')}</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
|
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}{pendingNew && !creating && <DraftMark />}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailPopup open={trashOpen} title={t('activities_trash')} onClose={() => setTrashOpen(false)} width={480} height={420}>
|
<DetailPopup open={trashOpen} title={t('activities_trash')} onClose={() => setTrashOpen(false)} width={480} height={420}>
|
||||||
@@ -271,17 +314,18 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
)}
|
)}
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|
||||||
<DetailPopup open={!!converting} title={t('activity_convert_title')} onClose={() => setConverting(null)} width={520} height={360}>
|
<DetailPopup open={!!converting} title={t('activity_convert_title')} onClose={() => guardedClose(convertDraft, () => setConverting(null))} width={520} height={400}>
|
||||||
{converting && (
|
{converting && (
|
||||||
<>
|
<>
|
||||||
|
<DraftNotice draft={convertDraft} />
|
||||||
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 12 }}>{t('activity_convert_hint')}</div>
|
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 12 }}>{t('activity_convert_hint')}</div>
|
||||||
<Field label={t('activity_convert_project_name')}>
|
<Field label={t('activity_convert_project_name')} dirty={convertDraft.isFieldDirty('projectName')}>
|
||||||
<input
|
<input
|
||||||
className="input" value={convertForm.projectName} autoFocus
|
className="input" value={convertForm.projectName} autoFocus
|
||||||
onChange={(e) => setConvertForm((f) => ({ ...f, projectName: e.target.value }))}
|
onChange={(e) => setConvertForm((f) => ({ ...f, projectName: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('activity_convert_activity_name')}>
|
<Field label={t('activity_convert_activity_name')} dirty={convertDraft.isFieldDirty('taskActivityName')}>
|
||||||
<input
|
<input
|
||||||
className="input" value={convertForm.taskActivityName}
|
className="input" value={convertForm.taskActivityName}
|
||||||
onChange={(e) => setConvertForm((f) => ({ ...f, taskActivityName: e.target.value }))}
|
onChange={(e) => setConvertForm((f) => ({ ...f, taskActivityName: e.target.value }))}
|
||||||
@@ -294,38 +338,39 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
>
|
>
|
||||||
{t('activity_convert')}
|
{t('activity_convert')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="standard" onClick={() => setConverting(null)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { convertDraft.discard(); setConverting(null); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|
||||||
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
|
<DetailPopup open={creating} title={t('activity_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={640} height={600}>
|
||||||
<Field label={t('common_name')}>
|
<DraftNotice draft={draft} />
|
||||||
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
||||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_description')}>
|
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
|
||||||
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('common_responsible')}>
|
<Field label={t('common_responsible')} dirty={draft.isFieldDirty('responsibleUserId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_priority')}>
|
<Field label={t('common_priority')} dirty={draft.isFieldDirty('priorityId')}>
|
||||||
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_status')}>
|
<Field label={t('common_status')} dirty={draft.isFieldDirty('statusId')}>
|
||||||
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('activity_start_date')}>
|
<Field label={t('activity_start_date')} dirty={draft.isFieldDirty('startDate')}>
|
||||||
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('activity_due_date')}>
|
<Field label={t('activity_due_date')} dirty={draft.isFieldDirty('dueDate')}>
|
||||||
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
|
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('activity_milestone')}>
|
<Field label={t('activity_milestone')} dirty={draft.isFieldDirty('isMilestone')}>
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
|
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
|
||||||
{t('activity_milestone')}
|
{t('activity_milestone')}
|
||||||
@@ -334,18 +379,20 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
|||||||
</FieldRow>
|
</FieldRow>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" onClick={() => setCreating(false)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { draft.discard(); setCreating(false); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|
||||||
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setSelected(null)} width={720} height={680}>
|
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setActivityParams(null)} width={720} height={680}>
|
||||||
{selected && (
|
{selected && (
|
||||||
<ActivityDetailPanel
|
<ActivityDetailPanel
|
||||||
|
key={selected.id}
|
||||||
activity={selected}
|
activity={selected}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
siblingActivities={activities}
|
siblingActivities={activities}
|
||||||
onChanged={refreshAll}
|
onChanged={refreshAll}
|
||||||
initialTab={selectedInitialTab}
|
tab={activityTab}
|
||||||
|
onTabChange={(key) => setActivityParams(selected.id, key)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Pencil, Save, X } from 'lucide-react';
|
import { Pencil, Save, X } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import { updateProject } from '../../services/projects.js';
|
import { updateProject, getProject } from '../../services/projects.js';
|
||||||
import { listCatalog } from '../../services/catalogs.js';
|
import { listCatalog } from '../../services/catalogs.js';
|
||||||
import { listDepartments } from '../../services/departments.js';
|
import { listDepartments } from '../../services/departments.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
@@ -11,6 +11,8 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
|||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
|
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
|
||||||
function flatten(nodes, acc = []) {
|
function flatten(nodes, acc = []) {
|
||||||
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
|
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
|
||||||
@@ -21,6 +23,7 @@ const RISK_COLOR = { low: 'success', medium: 'warning', high: 'danger' };
|
|||||||
|
|
||||||
function toForm(p) {
|
function toForm(p) {
|
||||||
return {
|
return {
|
||||||
|
editVersion: p.edit_version,
|
||||||
name: p.name, description: p.description || '', objective: p.objective || '',
|
name: p.name, description: p.description || '', objective: p.objective || '',
|
||||||
projectTypeId: p.project_type_id || '', priorityId: p.priority_id, statusId: p.status_id,
|
projectTypeId: p.project_type_id || '', priorityId: p.priority_id, statusId: p.status_id,
|
||||||
ownerUserId: p.owner_user_id,
|
ownerUserId: p.owner_user_id,
|
||||||
@@ -40,9 +43,16 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
|||||||
const { data: users } = useAsync(listUsers, []);
|
const { data: users } = useAsync(listUsers, []);
|
||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [form, setForm] = useState(() => toForm(project));
|
// Borrador de la edicion: sobrevive a cambiar de pestaña/menu, F5 y vencimiento de sesion.
|
||||||
|
const draft = useDraftForm({
|
||||||
useEffect(() => { setForm(toForm(project)); }, [project]);
|
formId: 'project-info', entityId: project.id, initial: toForm(project),
|
||||||
|
label: `${project.code} — ${t('tab_info')}`, onDiscard: () => setEditing(false),
|
||||||
|
});
|
||||||
|
const form = draft.values;
|
||||||
|
const setForm = draft.setValues;
|
||||||
|
const dirty = (name) => editing && draft.isFieldDirty(name);
|
||||||
|
// Al volver con un borrador pendiente, la edicion se reabre con los valores recuperados.
|
||||||
|
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
|
||||||
|
|
||||||
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||||
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||||
@@ -50,10 +60,10 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
|||||||
const typeOptions = (types || []).map((tp) => ({ value: tp.id, label: tp.name }));
|
const typeOptions = (types || []).map((tp) => ({ value: tp.id, label: tp.name }));
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const changed = JSON.stringify(form) !== JSON.stringify(toForm(project));
|
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
|
||||||
if (!changed) { setEditing(false); return; }
|
await draft.guardSave(() => runAction(async () => {
|
||||||
await runAction(async () => {
|
|
||||||
await updateProject(project.id, {
|
await updateProject(project.id, {
|
||||||
|
editVersion: form.editVersion,
|
||||||
...form,
|
...form,
|
||||||
projectTypeId: form.projectTypeId || null,
|
projectTypeId: form.projectTypeId || null,
|
||||||
requestingDepartmentId: form.requestingDepartmentId || null,
|
requestingDepartmentId: form.requestingDepartmentId || null,
|
||||||
@@ -62,10 +72,11 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
|||||||
estimatedEndDate: form.estimatedEndDate || null,
|
estimatedEndDate: form.estimatedEndDate || null,
|
||||||
actualEndDate: form.actualEndDate || null,
|
actualEndDate: form.actualEndDate || null,
|
||||||
});
|
});
|
||||||
|
draft.clear();
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
onChanged();
|
onChanged();
|
||||||
});
|
}), { fetchCurrent: async () => toForm(await getProject(project.id)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +85,7 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" icon={X} onClick={() => { setForm(toForm(project)); setEditing(false); }}>{t('common_cancel')}</Button>
|
<Button variant="standard" icon={X} onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
||||||
@@ -108,54 +119,55 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="panel-card">
|
<div className="panel-card">
|
||||||
<Field label={t('common_name')}>
|
<DraftNotice draft={draft} />
|
||||||
|
<Field label={t('common_name')} dirty={dirty('name')}>
|
||||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_description')}>
|
<Field label={t('common_description')} dirty={dirty('description')}>
|
||||||
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_objective')}>
|
<Field label={t('project_objective')} dirty={dirty('objective')}>
|
||||||
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
|
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('project_type')}>
|
<Field label={t('project_type')} dirty={dirty('projectTypeId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_priority')}>
|
<Field label={t('common_priority')} dirty={dirty('priorityId')}>
|
||||||
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_status')}>
|
<Field label={t('common_status')} dirty={dirty('statusId')}>
|
||||||
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('project_owner')}>
|
<Field label={t('project_owner')} dirty={dirty('ownerUserId')}>
|
||||||
<ComboBox options={(users || []).map((u) => ({ value: u.id, label: u.name }))} value={form.ownerUserId} onChange={(v) => setForm((f) => ({ ...f, ownerUserId: v }))} />
|
<ComboBox options={(users || []).map((u) => ({ value: u.id, label: u.name }))} value={form.ownerUserId} onChange={(v) => setForm((f) => ({ ...f, ownerUserId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_risk')}>
|
<Field label={t('project_risk')} dirty={dirty('riskLevel')}>
|
||||||
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
|
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('project_requesting_department')}>
|
<Field label={t('project_requesting_department')} dirty={dirty('requestingDepartmentId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_responsible_department')}>
|
<Field label={t('project_responsible_department')} dirty={dirty('responsibleDepartmentId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('project_start_date')}>
|
<Field label={t('project_start_date')} dirty={dirty('startDate')}>
|
||||||
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_estimated_end_date')}>
|
<Field label={t('project_estimated_end_date')} dirty={dirty('estimatedEndDate')}>
|
||||||
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
|
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_actual_end_date')}>
|
<Field label={t('project_actual_end_date')} dirty={dirty('actualEndDate')}>
|
||||||
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
|
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<Field label={t('project_notes')}>
|
<Field label={t('project_notes')} dirty={dirty('notes')}>
|
||||||
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { UserPlus, Trash2 } from 'lucide-react';
|
import { UserPlus, Trash2 } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
@@ -10,6 +10,9 @@ import { Field } from '../../components/Field.jsx';
|
|||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
|
||||||
|
|
||||||
const ROLE_BADGE = {
|
const ROLE_BADGE = {
|
||||||
owner: { color: 'accent', key: 'project_role_owner' },
|
owner: { color: 'accent', key: 'project_role_owner' },
|
||||||
@@ -22,10 +25,20 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
|||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: users } = useAsync(listUsers, []);
|
const { data: users } = useAsync(listUsers, []);
|
||||||
|
|
||||||
const [addingMember, setAddingMember] = useState('');
|
|
||||||
const [inviteEmail, setInviteEmail] = useState('');
|
|
||||||
const [newMemberRole, setNewMemberRole] = useState('read');
|
|
||||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||||
|
// Borrador del popup "Agregar miembro" (por proyecto).
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: 'project-member-add', entityId: project.id,
|
||||||
|
initial: { userId: '', email: '', role: 'read' },
|
||||||
|
label: `${project.code} — ${t('project_add_member')}`, enabled: addMemberOpen,
|
||||||
|
});
|
||||||
|
const addingMember = draft.values.userId;
|
||||||
|
const inviteEmail = draft.values.email;
|
||||||
|
const newMemberRole = draft.values.role;
|
||||||
|
const setNewMemberRole = (v) => draft.setField('role', v);
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
const pendingAdd = hasDraftFor(useUserDrafts(), 'project-member-add', project.id);
|
||||||
|
useEffect(() => { if (pendingAdd) setAddMemberOpen(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Compartir (agregar/quitar/cambiar permiso) es exclusivo del dueño del proyecto -- el resto
|
// Compartir (agregar/quitar/cambiar permiso) es exclusivo del dueño del proyecto -- el resto
|
||||||
// solo ve la lista de con quien esta compartido.
|
// solo ve la lista de con quien esta compartido.
|
||||||
@@ -42,9 +55,7 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
|||||||
? { email: inviteEmail.trim() }
|
? { email: inviteEmail.trim() }
|
||||||
: { userId: Number(addingMember) };
|
: { userId: Number(addingMember) };
|
||||||
const result = await addProjectMember(project.id, target, newMemberRole === 'write' ? 'write' : null);
|
const result = await addProjectMember(project.id, target, newMemberRole === 'write' ? 'write' : null);
|
||||||
setAddingMember('');
|
draft.clear();
|
||||||
setInviteEmail('');
|
|
||||||
setNewMemberRole('read');
|
|
||||||
setAddMemberOpen(false);
|
setAddMemberOpen(false);
|
||||||
onChanged();
|
onChanged();
|
||||||
pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') });
|
pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') });
|
||||||
@@ -75,7 +86,7 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
|||||||
<div className="panel-card">
|
<div className="panel-card">
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
|
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
|
||||||
{canManage && <Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button>}
|
{canManage && <Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}{pendingAdd && !addMemberOpen && <DraftMark />}</Button>}
|
||||||
</div>
|
</div>
|
||||||
{!canManage && <div className="text-muted" style={{ fontSize: '.76rem', marginBottom: 10 }}>{t('project_members_owner_only_hint')}</div>}
|
{!canManage && <div className="text-muted" style={{ fontSize: '.76rem', marginBottom: 10 }}>{t('project_members_owner_only_hint')}</div>}
|
||||||
{project.members.map((m) => {
|
{project.members.map((m) => {
|
||||||
@@ -103,29 +114,30 @@ export default function ProjectMembersTab({ project, onChanged }) {
|
|||||||
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={420}>
|
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => guardedClose(draft, () => setAddMemberOpen(false))} width={420} height={460}>
|
||||||
<Field label={t('project_add_member')}>
|
<DraftNotice draft={draft} />
|
||||||
|
<Field label={t('project_add_member')} dirty={draft.isFieldDirty('userId')}>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
options={userOptions}
|
options={userOptions}
|
||||||
value={addingMember}
|
value={addingMember}
|
||||||
onChange={(v) => { setAddingMember(v); setInviteEmail(''); }}
|
onChange={(v) => draft.setValues((f) => ({ ...f, userId: v, email: '' }))}
|
||||||
placeholder={t('project_add_member')}
|
placeholder={t('project_add_member')}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div className="text-muted" style={{ fontSize: '.76rem', margin: '8px 0', textAlign: 'center' }}>{t('common_or')}</div>
|
<div className="text-muted" style={{ fontSize: '.76rem', margin: '8px 0', textAlign: 'center' }}>{t('common_or')}</div>
|
||||||
<Field label={t('project_invite_email')} hint={t('project_invite_email_hint')}>
|
<Field label={t('project_invite_email')} hint={t('project_invite_email_hint')} dirty={draft.isFieldDirty('email')}>
|
||||||
<input
|
<input
|
||||||
className="input" type="email" value={inviteEmail}
|
className="input" type="email" value={inviteEmail}
|
||||||
onChange={(e) => { setInviteEmail(e.target.value); setAddingMember(''); }}
|
onChange={(e) => draft.setValues((f) => ({ ...f, email: e.target.value, userId: '' }))}
|
||||||
placeholder="correo@dominio.com"
|
placeholder="correo@dominio.com"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_add_member_role')}>
|
<Field label={t('project_add_member_role')} dirty={draft.isFieldDirty('role')}>
|
||||||
<ComboBox options={roleOptions} value={newMemberRole} onChange={setNewMemberRole} />
|
<ComboBox options={roleOptions} value={newMemberRole} onChange={setNewMemberRole} />
|
||||||
</Field>
|
</Field>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
<Button variant="accent" icon={UserPlus} onClick={addMember} disabled={!canAdd}>{t('common_save')}</Button>
|
<Button variant="accent" icon={UserPlus} onClick={addMember} disabled={!canAdd}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { draft.discard(); setAddMemberOpen(false); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAsync } from '../../hooks/useAsync.js';
|
import { useAsync } from '../../hooks/useAsync.js';
|
||||||
|
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||||
import { getProject } from '../../services/projects.js';
|
import { getProject } from '../../services/projects.js';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
import { Tabs } from '../../components/Tabs.jsx';
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
@@ -20,10 +20,26 @@ export default function ProyectoDetallePage() {
|
|||||||
const projectId = Number(id);
|
const projectId = Number(id);
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
// La pestaña vive en la URL (?tab=): asi el retorno tras el login (o un F5) vuelve a la misma
|
||||||
|
// pestaña, y el aviso de cambios sin guardar intercepta el cambio de pestaña.
|
||||||
// Llegada desde el buscador (Proyectos): ?tab=activities&activity=<id>&activityTab=tasks abre
|
// Llegada desde el buscador (Proyectos): ?tab=activities&activity=<id>&activityTab=tasks abre
|
||||||
// directo la actividad (y su pestaña de tareas) que coincidio con la busqueda.
|
// directo la actividad (y su pestaña de tareas) que coincidio con la busqueda.
|
||||||
const [tab, setTab] = useState(searchParams.get('tab') || 'info');
|
const tab = searchParams.get('tab') || 'info';
|
||||||
|
const setTab = (key) => setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.set('tab', key);
|
||||||
|
next.delete('activity');
|
||||||
|
next.delete('activityTab');
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
// Pestañas con borradores pendientes de este proyecto (el borrador guarda la URL con su ?tab=).
|
||||||
|
const drafts = useUserDrafts();
|
||||||
|
const dirtyTab = (key) => drafts.some((d) => {
|
||||||
|
if (typeof d.path !== 'string') return false;
|
||||||
|
const [path, query = ''] = d.path.split('?');
|
||||||
|
return path === `/proyectos/${projectId}` && (new URLSearchParams(query).get('tab') || 'info') === key;
|
||||||
|
});
|
||||||
const deepLinkActivityId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
|
const deepLinkActivityId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
|
||||||
const deepLinkActivityTab = searchParams.get('activityTab') || undefined;
|
const deepLinkActivityTab = searchParams.get('activityTab') || undefined;
|
||||||
|
|
||||||
@@ -43,13 +59,13 @@ export default function ProyectoDetallePage() {
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'info', label: t('tab_info') },
|
{ key: 'info', label: t('tab_info'), dirty: dirtyTab('info') },
|
||||||
{ key: 'members', label: t('tab_members'), count: project.members_count },
|
{ key: 'members', dirty: dirtyTab('members'), label: t('tab_members'), count: project.members_count },
|
||||||
{ key: 'activities', label: t('tab_activities'), count: `${project.activities_complete}/${project.activities_total}` },
|
{ key: 'activities', dirty: dirtyTab('activities'), label: t('tab_activities'), count: `${project.activities_complete}/${project.activities_total}` },
|
||||||
{ key: 'gantt', label: t('tab_gantt') },
|
{ key: 'gantt', label: t('tab_gantt') },
|
||||||
{ key: 'calendar', label: t('tab_calendar') },
|
{ key: 'calendar', label: t('tab_calendar') },
|
||||||
{ key: 'comments', label: t('tab_comments'), count: project.comments_count },
|
{ key: 'comments', dirty: dirtyTab('comments'), label: t('tab_comments'), count: project.comments_count },
|
||||||
{ key: 'documents', label: t('tab_documents'), count: project.documents_count },
|
{ key: 'documents', dirty: dirtyTab('documents'), label: t('tab_documents'), count: project.documents_count },
|
||||||
]}
|
]}
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Plus, Save, Trash2, RotateCcw, ArrowLeft } from 'lucide-react';
|
import { Plus, Save, Trash2, RotateCcw, ArrowLeft } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
@@ -19,6 +19,9 @@ import { ComboBox } from '../../components/ComboBox.jsx';
|
|||||||
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
|
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
|
||||||
import { SharedWithIcon } from '../../components/SharedWithIcon.jsx';
|
import { SharedWithIcon } from '../../components/SharedWithIcon.jsx';
|
||||||
|
|
||||||
const CAN_DELETE_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
|
const CAN_DELETE_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
|
||||||
@@ -50,6 +53,8 @@ export default function ProyectosPage() {
|
|||||||
const [filterStatusId, setFilterStatusId] = useState('');
|
const [filterStatusId, setFilterStatusId] = useState('');
|
||||||
const [filterPriorityId, setFilterPriorityId] = useState('');
|
const [filterPriorityId, setFilterPriorityId] = useState('');
|
||||||
const [filterDueSoon, setFilterDueSoon] = useState(false);
|
const [filterDueSoon, setFilterDueSoon] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
// '' = todos, 'hide' = ocultar 100%, 'only' = solo 100%. Persistido en el navegador (sobrevive
|
// '' = todos, 'hide' = ocultar 100%, 'only' = solo 100%. Persistido en el navegador (sobrevive
|
||||||
// al cierre de sesion). Respeta el checkbox anterior "Ocultar 100%" (clave vieja) si existia.
|
// al cierre de sesion). Respeta el checkbox anterior "Ocultar 100%" (clave vieja) si existia.
|
||||||
const [progressFilter, setProgressFilter] = useState(() => {
|
const [progressFilter, setProgressFilter] = useState(() => {
|
||||||
@@ -67,25 +72,36 @@ export default function ProyectosPage() {
|
|||||||
// Prueba exploratoria: filtro "ver todos / por usuario" solo para roles que ya ven todos los
|
// Prueba exploratoria: filtro "ver todos / por usuario" solo para roles que ya ven todos los
|
||||||
// proyectos hoy (superadmin/admin/it_manager) -- backend ya soporta ownerUserId, sin cambios.
|
// proyectos hoy (superadmin/admin/it_manager) -- backend ya soporta ownerUserId, sin cambios.
|
||||||
const canFilterByUser = ['superadmin', 'admin', 'it_manager'].includes(currentUser?.roleCode);
|
const canFilterByUser = ['superadmin', 'admin', 'it_manager'].includes(currentUser?.roleCode);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debouncedSearch = useDebouncedValue(search, 300);
|
||||||
|
|
||||||
const { data: projects, reload } = useAsync(
|
const { data: projectPage, reload } = useAsync(
|
||||||
() => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined }),
|
() => listProjects(showTrash ? { trash: true, page, pageSize, q: debouncedSearch } : {
|
||||||
[showTrash, filterStatusId, filterPriorityId, filterOwnerUserId]
|
statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined,
|
||||||
|
dueSoon: filterDueSoon, progress: progressFilter, q: debouncedSearch, page, pageSize,
|
||||||
|
}),
|
||||||
|
[showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, debouncedSearch, page, pageSize]
|
||||||
);
|
);
|
||||||
|
const projects = projectPage?.items;
|
||||||
const { data: allUsers } = useAsync(listUsers, []);
|
const { data: allUsers } = useAsync(listUsers, []);
|
||||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||||
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
|
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
|
||||||
const { data: types } = useAsync(() => listCatalog('project-types'), []);
|
const { data: types } = useAsync(() => listCatalog('project-types'), []);
|
||||||
const { data: deptTree } = useAsync(listDepartments, []);
|
const { data: deptTree } = useAsync(listDepartments, []);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
useEffect(() => { setPage(1); }, [showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, search, pageSize]);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
// "Nuevo proyecto" con borrador: si se sale a medio llenar, al volver se reabre el popup.
|
||||||
|
const draft = useDraftForm({ formId: 'project-new', entityId: null, initial: EMPTY_FORM, label: t('project_new'), enabled: creating });
|
||||||
|
const form = draft.values;
|
||||||
|
const setForm = draft.setValues;
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
const pendingNew = hasDraftFor(useUserDrafts(), 'project-new', null);
|
||||||
|
useEffect(() => { if (pendingNew) setCreating(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Busca tambien en actividades y tareas de CUALQUIER proyecto (no solo el nombre del proyecto,
|
// Busca tambien en actividades y tareas de CUALQUIER proyecto (no solo el nombre del proyecto,
|
||||||
// que ya filtra la tabla mas abajo via getSearchHaystack) — resultados con su ruta completa
|
// que ya filtra la tabla mas abajo via getSearchHaystack) — resultados con su ruta completa
|
||||||
// "Proyecto - Actividad - Tarea" en un panel flotante bajo el buscador.
|
// "Proyecto - Actividad - Tarea" en un panel flotante bajo el buscador.
|
||||||
const debouncedSearch = useDebouncedValue(search, 300);
|
|
||||||
const searchQuery = debouncedSearch.trim();
|
const searchQuery = debouncedSearch.trim();
|
||||||
const { data: searchResults } = useAsync(
|
const { data: searchResults } = useAsync(
|
||||||
() => (searchQuery.length >= 2 ? searchApi(searchQuery) : Promise.resolve(null)),
|
() => (searchQuery.length >= 2 ? searchApi(searchQuery) : Promise.resolve(null)),
|
||||||
@@ -125,8 +141,8 @@ export default function ProyectosPage() {
|
|||||||
startDate: form.startDate || null,
|
startDate: form.startDate || null,
|
||||||
estimatedEndDate: form.estimatedEndDate || null,
|
estimatedEndDate: form.estimatedEndDate || null,
|
||||||
});
|
});
|
||||||
|
draft.clear();
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setForm(EMPTY_FORM);
|
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
navigate(`/proyectos/${project.id}`);
|
navigate(`/proyectos/${project.id}`);
|
||||||
});
|
});
|
||||||
@@ -193,16 +209,7 @@ export default function ProyectosPage() {
|
|||||||
|
|
||||||
if (!projects) return <div className="text-muted">{t('common_loading')}</div>;
|
if (!projects) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
|
||||||
const dueSoonProjects = filterDueSoon && !showTrash
|
const visibleProjects = projects;
|
||||||
? projects.filter((p) => {
|
|
||||||
if (p.status_is_closed || !p.estimated_end_date) return false;
|
|
||||||
const diffDays = Math.round((new Date(p.estimated_end_date) - new Date()) / 86400000);
|
|
||||||
return diffDays >= 0 && diffDays <= 7;
|
|
||||||
})
|
|
||||||
: projects;
|
|
||||||
const visibleProjects = showTrash || !progressFilter
|
|
||||||
? dueSoonProjects
|
|
||||||
: dueSoonProjects.filter((p) => (Number(p.progress_pct) >= 100) === (progressFilter === 'only'));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -217,7 +224,7 @@ export default function ProyectosPage() {
|
|||||||
{isSuperadmin && (
|
{isSuperadmin && (
|
||||||
<Button variant="standard" icon={Trash2} onClick={() => setShowTrash(true)}>{t('project_trash')}</Button>
|
<Button variant="standard" icon={Trash2} onClick={() => setShowTrash(true)}>{t('project_trash')}</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}</Button>
|
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}{pendingNew && !creating && <DraftMark />}</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -284,7 +291,7 @@ export default function ProyectosPage() {
|
|||||||
<ListView
|
<ListView
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={visibleProjects}
|
rows={visibleProjects}
|
||||||
searchValue={search}
|
searchValue=""
|
||||||
getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`}
|
getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`}
|
||||||
getRowKey={(p) => p.id}
|
getRowKey={(p) => p.id}
|
||||||
onRowClick={showTrash ? undefined : (p) => navigate(`/proyectos/${p.id}`)}
|
onRowClick={showTrash ? undefined : (p) => navigate(`/proyectos/${p.id}`)}
|
||||||
@@ -301,52 +308,60 @@ export default function ProyectosPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailPopup open={creating} title={t('project_new')} onClose={() => setCreating(false)} width={720} height={720}>
|
<div className="toolbar" style={{ justifyContent: 'flex-end', marginTop: 8 }}>
|
||||||
<Field label={t('common_name')}>
|
<span className="text-muted">{t('project_page_range')} {projectPage.total ? (page - 1) * pageSize + 1 : 0}–{Math.min(page * pageSize, projectPage.total)} {t('project_page_of')} {projectPage.total}</span>
|
||||||
|
<ComboBox options={[10, 20, 50, 100].map((n) => ({ value: n, label: String(n) }))} value={pageSize} onChange={setPageSize} />
|
||||||
|
<Button variant="standard" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>{t('project_page_previous')}</Button>
|
||||||
|
<Button variant="standard" disabled={page * pageSize >= projectPage.total} onClick={() => setPage((p) => p + 1)}>{t('project_page_next')}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailPopup open={creating} title={t('project_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={720} height={720}>
|
||||||
|
<DraftNotice draft={draft} />
|
||||||
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
||||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_description')}>
|
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
|
||||||
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_objective')}>
|
<Field label={t('project_objective')} dirty={draft.isFieldDirty('objective')}>
|
||||||
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
|
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('project_type')}>
|
<Field label={t('project_type')} dirty={draft.isFieldDirty('projectTypeId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_priority')}>
|
<Field label={t('common_priority')} dirty={draft.isFieldDirty('priorityId')}>
|
||||||
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('common_status')}>
|
<Field label={t('common_status')} dirty={draft.isFieldDirty('statusId')}>
|
||||||
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('project_requesting_department')}>
|
<Field label={t('project_requesting_department')} dirty={draft.isFieldDirty('requestingDepartmentId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_responsible_department')}>
|
<Field label={t('project_responsible_department')} dirty={draft.isFieldDirty('responsibleDepartmentId')}>
|
||||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
|
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_risk')}>
|
<Field label={t('project_risk')} dirty={draft.isFieldDirty('riskLevel')}>
|
||||||
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
|
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('project_start_date')}>
|
<Field label={t('project_start_date')} dirty={draft.isFieldDirty('startDate')}>
|
||||||
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('project_estimated_end_date')}>
|
<Field label={t('project_estimated_end_date')} dirty={draft.isFieldDirty('estimatedEndDate')}>
|
||||||
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
|
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<Field label={t('project_notes')}>
|
<Field label={t('project_notes')} dirty={draft.isFieldDirty('notes')}>
|
||||||
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" onClick={() => setCreating(false)}>{t('common_cancel')}</Button>
|
<Button variant="standard" onClick={() => { draft.discard(); setCreating(false); }}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user