From 72b616bd02e3c992b1fd54a6223f6c0a83c95ee8 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Fri, 11 Sep 2026 10:17:02 -0500 Subject: [PATCH] feat: Tareas recurrentes con recordatorio por correo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu propio en la barra lateral, independiente de Proyectos: - Periodicidad configurable (cada N dias/meses/años), proximo vencimiento, responsable, proyecto de origen opcional. - "Completar ciclo" avanza el proximo vencimiento sumando la periodicidad y limpia el recordatorio ya enviado. - Recordatorio por correo automatico N dias antes del vencimiento (default 7), a una hora del dia configurable. Un job en el propio proceso del backend (setInterval cada 15 min) revisa y envia, sin infraestructura nueva (cron del SO, cola, etc). - Desde la pestaña Tareas de una actividad, cada tarea tiene un boton "Convertir en recurrente" que la promueve (la tarea original no se borra). Probado en dev con curl (crear, completar ciclo avanza next_due_date, promover desde una tarea real, disparo real del job de recordatorios verificado forzando fecha/hora, segunda corrida no reenvia) y Playwright (listado, popup de creacion, popup de promocion anidado sobre el detalle de actividad) sin errores de consola. Co-Authored-By: Claude Sonnet 5 --- API.md | 13 ++ BASE_DE_DATOS.md | 8 + CHANGELOG.md | 11 + VERSION | 2 +- .../migrations/010_recurring_tasks.sql | 24 +++ backend/package.json | 2 +- backend/src/app.js | 2 + backend/src/jobs/recurringTaskReminders.js | 20 ++ .../src/modules/recurring-tasks/controller.js | 27 +++ .../src/modules/recurring-tasks/repository.js | 100 +++++++++ backend/src/modules/recurring-tasks/routes.js | 18 ++ .../src/modules/recurring-tasks/service.js | 107 ++++++++++ backend/src/server.js | 3 + frontend/package.json | 2 +- frontend/src/app/App.jsx | 2 + frontend/src/components/NavigationView.jsx | 3 +- frontend/src/i18n/messages.js | 40 ++++ .../pages/Proyectos/ActivityDetailPanel.jsx | 82 ++++++- .../RecurringTasks/RecurringTasksPage.jsx | 200 ++++++++++++++++++ frontend/src/services/recurringTasks.js | 8 + 20 files changed, 669 insertions(+), 5 deletions(-) create mode 100644 backend/database/migrations/010_recurring_tasks.sql create mode 100644 backend/src/jobs/recurringTaskReminders.js create mode 100644 backend/src/modules/recurring-tasks/controller.js create mode 100644 backend/src/modules/recurring-tasks/repository.js create mode 100644 backend/src/modules/recurring-tasks/routes.js create mode 100644 backend/src/modules/recurring-tasks/service.js create mode 100644 frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx create mode 100644 frontend/src/services/recurringTasks.js diff --git a/API.md b/API.md index 2e83825..0ef71aa 100644 --- a/API.md +++ b/API.md @@ -76,6 +76,19 @@ ### Tareas (`/api/activities/:activityId/tasks`) - `GET` · `POST` · `PUT /api/tasks/:id` · `PATCH /api/tasks/:id/done` · `DELETE /api/tasks/:id` +### Tareas recurrentes (`/api/recurring-tasks`) — menú propio, independiente de Proyectos +- `GET` · `POST` · `PUT /api/recurring-tasks/:id` · `DELETE /api/recurring-tasks/:id` +- `POST /api/recurring-tasks/:id/complete` — cierra el ciclo actual y calcula el próximo + vencimiento sumando la periodicidad (`interval_value` + `interval_unit`). +- `POST /api/recurring-tasks/from-task/:taskId` — crea una tarea recurrente a partir de una tarea + puntual de una actividad (la original no se borra); mismo body que `POST /`. +- Body: `{ name, description?, projectId?, responsibleUserId?, intervalUnit: 'days'|'months'|'years', + intervalValue, nextDueDate, reminderDaysBefore, reminderHour: 0-23 }`. +- Recordatorio por correo: un job en el propio proceso del backend revisa cada 15 minutos + (`src/jobs/recurringTaskReminders.js`) y manda el correo cuando `next_due_date - + reminderDaysBefore` cae hoy, a la hora `reminderHour`. Un solo envío por ciclo + (`last_reminder_sent_at`, se limpia al completar el ciclo). + ### Comentarios (`/api/comments`) - `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments` diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 1d60f23..db33964 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -33,6 +33,7 @@ | `activities` | actividades de un proyecto | | `activity_dependencies` | dependencias simples entre actividades ("depende de") | | `tasks` | checklist liviano dentro de una actividad ("Tareas" en la UI; renombrada desde `subtasks` en v1.8.0) | +| `recurring_tasks` | recordatorios periódicos (ej. renovación de licencias), menú propio "Tareas recurrentes" | | `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) | | `attachments` | archivos adjuntos a proyecto o actividad | | `notifications` | notificaciones in-app por usuario | @@ -70,6 +71,13 @@ `pending_approval`** — a diferencia del autorregistro público, aquí quien lo invitó ya lo vetó al agregarlo a su proyecto. La fila de `project_members` se crea en el momento de invitar, no al aceptar, así que en cuanto acepta ya aparece como miembro sin pasos adicionales. +- `recurring_tasks`: `project_id`/`responsible_user_id` son **opcionales** (NULL) — se puede crear + directo desde "Tareas recurrentes" o promoviendo una tarea puntual de una actividad + (`project_id` queda como referencia del proyecto de origen, la tarea puntual original NO se + borra). `last_reminder_sent_at` se limpia a NULL cada vez que se completa un ciclo + (`POST /:id/complete`, que suma `interval_value`/`interval_unit` a `next_due_date`) — es lo que + evita reenviar el correo dos veces en el mismo ciclo. El envío real lo hace un job en el propio + proceso del backend (`setInterval` cada 15 min, sin cron del SO ni cola nueva). - `system_settings.value` para `smtp_pass_enc` va **cifrado** (AES-256-GCM, `ENCRYPTION_KEY` del `.env`) — nunca en texto plano, nunca se devuelve por la API (solo `configured: true/false`). - Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con diff --git a/CHANGELOG.md b/CHANGELOG.md index 4962269..eb98569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog - project.linkedcorp.net +## [1.12.0] - 2026-09-11 +### Added +- **Tareas recurrentes**: menú propio en la barra lateral, independiente de Proyectos. Cada tarea + recurrente tiene periodicidad configurable (cada N días/meses/años), próximo vencimiento, + responsable y proyecto de origen opcional. "Completar ciclo" avanza el próximo vencimiento + sumando la periodicidad. Recordatorio por correo automático N días antes del vencimiento + (configurable, default 7) a una hora del día configurable — un job en el propio proceso del + backend revisa cada 15 minutos, sin infraestructura nueva. +- Desde la pestaña "Tareas" de una actividad, cada tarea puntual tiene un botón "Convertir en + recurrente" que la promueve a una tarea recurrente (la tarea original no se borra). + ## [1.11.0] - 2026-09-11 ### Added - Filtros en el listado de Proyectos: Estado, Prioridad (ambos ya soportados por el backend, solo diff --git a/VERSION b/VERSION index 1cac385..0eed1a2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.0 +1.12.0 diff --git a/backend/database/migrations/010_recurring_tasks.sql b/backend/database/migrations/010_recurring_tasks.sql new file mode 100644 index 0000000..ae7f9fe --- /dev/null +++ b/backend/database/migrations/010_recurring_tasks.sql @@ -0,0 +1,24 @@ +-- Tareas recurrentes: recordatorios periodicos (ej. renovacion de licencias) con recordatorio +-- por correo N dias antes, a una hora configurable. Menu propio, independiente de Proyectos, pero +-- puede originarse promoviendo una tarea existente de un proyecto (project_id queda como +-- referencia/contexto, nunca obligatorio). +CREATE TABLE IF NOT EXISTS recurring_tasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(160) NOT NULL, + description TEXT NULL, + project_id INT NULL, + responsible_user_id INT NULL, + interval_unit ENUM('days','months','years') NOT NULL DEFAULT 'months', + interval_value INT NOT NULL DEFAULT 1, + next_due_date DATE NOT NULL, + reminder_days_before INT NOT NULL DEFAULT 7, + reminder_hour TINYINT NOT NULL DEFAULT 9, + is_active TINYINT(1) NOT NULL DEFAULT 1, + last_reminder_sent_at DATETIME NULL, + created_by INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_recurring_tasks_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL, + CONSTRAINT fk_recurring_tasks_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_recurring_tasks_created_by FOREIGN KEY (created_by) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package.json b/backend/package.json index 97cabca..4c89fb8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.11.0", + "version": "1.12.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index c6a9110..7bb2d97 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -17,6 +17,7 @@ import notificationsRoutes from './modules/notifications/routes.js'; import dashboardRoutes from './modules/dashboard/routes.js'; import auditRoutes from './modules/audit/routes.js'; import settingsRoutes from './modules/settings/routes.js'; +import recurringTasksRoutes from './modules/recurring-tasks/routes.js'; export const app = express(); @@ -38,6 +39,7 @@ app.use('/api/notifications', notificationsRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/audit', auditRoutes); app.use('/api/settings', settingsRoutes); +app.use('/api/recurring-tasks', recurringTasksRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/jobs/recurringTaskReminders.js b/backend/src/jobs/recurringTaskReminders.js new file mode 100644 index 0000000..3e62de2 --- /dev/null +++ b/backend/src/jobs/recurringTaskReminders.js @@ -0,0 +1,20 @@ +import { sendDueReminders } from '../modules/recurring-tasks/service.js'; + +const CHECK_INTERVAL_MS = 15 * 60 * 1000; + +// Revisa cada 15 minutos si hay tareas recurrentes que avisar hoy a esta hora (ver +// repository.findDueForReminder). Corre en el mismo proceso del backend — sin infraestructura +// nueva (cron del SO, cola, etc.), consistente con que este es un servicio unico de larga +// duracion administrado por systemd. +export function startRecurringTaskReminderJob() { + const run = async () => { + try { + const sent = await sendDueReminders(); + if (sent > 0) console.log(`[recurring-tasks] ${sent} recordatorio(s) enviado(s)`); + } catch (err) { + console.error('[recurring-tasks] error revisando recordatorios', err.message); + } + }; + run(); + setInterval(run, CHECK_INTERVAL_MS); +} diff --git a/backend/src/modules/recurring-tasks/controller.js b/backend/src/modules/recurring-tasks/controller.js new file mode 100644 index 0000000..215baea --- /dev/null +++ b/backend/src/modules/recurring-tasks/controller.js @@ -0,0 +1,27 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getRecurringTasks = asyncHandler(async (req, res) => { + ok(res, await service.listRecurringTasks()); +}); + +export const postRecurringTask = asyncHandler(async (req, res) => { + ok(res, await service.createRecurringTask(req.body, req.currentUser), 201); +}); + +export const putRecurringTask = asyncHandler(async (req, res) => { + ok(res, await service.updateRecurringTask(Number(req.params.id), req.body, req.currentUser)); +}); + +export const deleteRecurringTask = asyncHandler(async (req, res) => { + await service.removeRecurringTask(Number(req.params.id), req.currentUser); + ok(res, { deleted: true }); +}); + +export const postCompleteCycle = asyncHandler(async (req, res) => { + ok(res, await service.completeCycle(Number(req.params.id), req.currentUser)); +}); + +export const postPromoteFromTask = asyncHandler(async (req, res) => { + ok(res, await service.promoteFromTask(Number(req.params.taskId), req.body, req.currentUser), 201); +}); diff --git a/backend/src/modules/recurring-tasks/repository.js b/backend/src/modules/recurring-tasks/repository.js new file mode 100644 index 0000000..f6a5d4a --- /dev/null +++ b/backend/src/modules/recurring-tasks/repository.js @@ -0,0 +1,100 @@ +import { pool } from '../../config/db.js'; + +const LIST_SELECT = ` + SELECT r.id, r.name, r.description, r.project_id, p.name AS project_name, + r.responsible_user_id, u.name AS responsible_name, + r.interval_unit, r.interval_value, r.next_due_date, + r.reminder_days_before, r.reminder_hour, r.is_active, r.last_reminder_sent_at, + r.created_by, r.created_at, r.updated_at + FROM recurring_tasks r + LEFT JOIN projects p ON p.id = r.project_id + LEFT JOIN users u ON u.id = r.responsible_user_id +`; + +export async function findAll() { + const [rows] = await pool.query(`${LIST_SELECT} ORDER BY r.next_due_date`); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query(`${LIST_SELECT} WHERE r.id = ?`, [id]); + return rows[0] || null; +} + +export async function create(data) { + const [result] = await pool.query( + `INSERT INTO recurring_tasks + (name, description, project_id, responsible_user_id, interval_unit, interval_value, + next_due_date, reminder_days_before, reminder_hour, is_active, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.name, data.description || null, data.projectId || null, data.responsibleUserId || null, + data.intervalUnit, data.intervalValue, data.nextDueDate, + data.reminderDaysBefore ?? 7, data.reminderHour ?? 9, + data.isActive === false ? 0 : 1, data.createdBy, + ] + ); + return findById(result.insertId); +} + +export async function update(id, data) { + await pool.query( + `UPDATE recurring_tasks SET + name = ?, description = ?, project_id = ?, responsible_user_id = ?, interval_unit = ?, + interval_value = ?, next_due_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ? + WHERE id = ?`, + [ + data.name, data.description || null, data.projectId || null, data.responsibleUserId || null, + data.intervalUnit, data.intervalValue, data.nextDueDate, + data.reminderDaysBefore ?? 7, data.reminderHour ?? 9, + data.isActive === false ? 0 : 1, id, + ] + ); + return findById(id); +} + +export async function remove(id) { + await pool.query('DELETE FROM recurring_tasks WHERE id = ?', [id]); +} + +const SQL_UNIT = { days: 'DAY', months: 'MONTH', years: 'YEAR' }; + +// Avanza al siguiente ciclo: suma el intervalo a next_due_date y limpia el recordatorio ya enviado +// (para que el proximo ciclo pueda volver a mandar su propio aviso). +export async function advanceCycle(id) { + const [rows] = await pool.query('SELECT interval_unit, interval_value FROM recurring_tasks WHERE id = ?', [id]); + const row = rows[0]; + if (!row) return null; + const unit = SQL_UNIT[row.interval_unit] || 'MONTH'; + await pool.query( + `UPDATE recurring_tasks + SET next_due_date = DATE_ADD(next_due_date, INTERVAL ? ${unit}), last_reminder_sent_at = NULL + WHERE id = ?`, + [row.interval_value, id] + ); + return findById(id); +} + +// Candidatas a recordatorio: activas, con la fecha de aviso (next_due_date - reminder_days_before) +// cayendo hoy, en la hora configurada, y que no se haya mandado ya el aviso de este ciclo +// (last_reminder_sent_at se limpia a NULL en cada advanceCycle, asi que "no nulo" = ya avisado). +export async function findDueForReminder() { + const [rows] = await pool.query( + `SELECT r.id, r.name, r.next_due_date, r.reminder_hour, r.project_id, p.name AS project_name, + r.responsible_user_id, u.email AS responsible_email, u.name AS responsible_name, + r.created_by, cu.email AS creator_email, cu.name AS creator_name + FROM recurring_tasks r + LEFT JOIN projects p ON p.id = r.project_id + LEFT JOIN users u ON u.id = r.responsible_user_id + JOIN users cu ON cu.id = r.created_by + WHERE r.is_active = 1 + AND r.last_reminder_sent_at IS NULL + AND DATE_SUB(r.next_due_date, INTERVAL r.reminder_days_before DAY) = CURDATE() + AND HOUR(NOW()) = r.reminder_hour` + ); + return rows; +} + +export async function markReminderSent(id) { + await pool.query('UPDATE recurring_tasks SET last_reminder_sent_at = NOW() WHERE id = ?', [id]); +} diff --git a/backend/src/modules/recurring-tasks/routes.js b/backend/src/modules/recurring-tasks/routes.js new file mode 100644 index 0000000..3a245db --- /dev/null +++ b/backend/src/modules/recurring-tasks/routes.js @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { + getRecurringTasks, postRecurringTask, putRecurringTask, deleteRecurringTask, + postCompleteCycle, postPromoteFromTask, +} from './controller.js'; + +const router = Router(); + +router.use(requireAuth); +router.get('/', getRecurringTasks); +router.post('/', postRecurringTask); +router.post('/from-task/:taskId', postPromoteFromTask); +router.put('/:id', putRecurringTask); +router.delete('/:id', deleteRecurringTask); +router.post('/:id/complete', postCompleteCycle); + +export default router; diff --git a/backend/src/modules/recurring-tasks/service.js b/backend/src/modules/recurring-tasks/service.js new file mode 100644 index 0000000..750d053 --- /dev/null +++ b/backend/src/modules/recurring-tasks/service.js @@ -0,0 +1,107 @@ +import * as repo from './repository.js'; +import * as tasksRepo from '../tasks/repository.js'; +import * as activitiesRepo from '../activities/repository.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { sendMail } from '../../utils/mailer.js'; + +const INTERVAL_UNITS = ['days', 'months', 'years']; + +function validate(payload) { + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + if (!INTERVAL_UNITS.includes(payload.intervalUnit)) throw new HttpError(400, 'Periodicidad invalida'); + if (!payload.intervalValue || payload.intervalValue < 1) throw new HttpError(400, 'La periodicidad debe ser al menos 1'); + if (!payload.nextDueDate) throw new HttpError(400, 'La fecha del proximo vencimiento es obligatoria'); + if (payload.reminderHour !== undefined && (payload.reminderHour < 0 || payload.reminderHour > 23)) { + throw new HttpError(400, 'La hora del recordatorio debe estar entre 0 y 23'); + } +} + +export const listRecurringTasks = () => repo.findAll(); + +export async function createRecurringTask(payload, currentUser) { + validate(payload); + const task = await repo.create({ ...payload, createdBy: currentUser.id }); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'recurring_task', entityId: task.id }); + return task; +} + +export async function updateRecurringTask(id, payload, currentUser) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada'); + validate(payload); + const task = await repo.update(id, payload); + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'recurring_task', entityId: id }); + return task; +} + +export async function removeRecurringTask(id, currentUser) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada'); + await repo.remove(id); + await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'recurring_task', entityId: id }); +} + +// Cierra el ciclo actual (ej. "ya renove la licencia") y calcula el proximo vencimiento sumando +// la periodicidad configurada. +export async function completeCycle(id, currentUser) { + const existing = await repo.findById(id); + if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada'); + const task = await repo.advanceCycle(id); + await logAudit({ userId: currentUser.id, action: 'complete_cycle', entityType: 'recurring_task', entityId: id }); + return task; +} + +// Convierte una tarea puntual de una actividad en una tarea recurrente: la tarea original de la +// 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. +export async function promoteFromTask(taskId, payload, currentUser) { + const sourceTask = await tasksRepo.findById(taskId); + if (!sourceTask) throw new HttpError(404, 'Tarea no encontrada'); + const activity = await activitiesRepo.findById(sourceTask.activity_id); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + + const merged = { + name: payload.name || sourceTask.name, + description: payload.description || null, + projectId: activity.project_id, + responsibleUserId: payload.responsibleUserId || sourceTask.responsible_user_id || null, + intervalUnit: payload.intervalUnit, + intervalValue: payload.intervalValue, + nextDueDate: payload.nextDueDate, + reminderDaysBefore: payload.reminderDaysBefore, + reminderHour: payload.reminderHour, + }; + validate(merged); + const task = await repo.create({ ...merged, createdBy: currentUser.id }); + await logAudit({ + userId: currentUser.id, action: 'promote_from_task', entityType: 'recurring_task', entityId: task.id, + detail: { sourceTaskId: taskId }, + }); + return task; +} + +// Revisa que tareas recurrentes necesitan aviso HOY a esta hora y manda el correo. Pensada para +// llamarse cada X minutos desde un job en proceso (ver src/jobs/recurringTaskReminders.js) — no +// lanza si falla un correo individual, para que uno malo no tumbe la revision de los demas. +export async function sendDueReminders() { + const due = await repo.findDueForReminder(); + for (const item of due) { + const to = item.responsible_email || item.creator_email; + const recipientName = item.responsible_name || item.creator_name; + try { + await sendMail({ + to, + subject: `Recordatorio: ${item.name} vence el ${item.next_due_date}`, + html: `

Hola ${recipientName},

+

La tarea recurrente ${item.name}${item.project_name ? ` (proyecto: ${item.project_name})` : ''} + vence el ${item.next_due_date}.

+

Entra a project.linkedcorp.net → Tareas recurrentes para revisarla.

`, + }); + } catch (err) { + console.error('[recurring-tasks] error enviando recordatorio', item.id, err.message); + } + await repo.markReminderSent(item.id); + } + return due.length; +} diff --git a/backend/src/server.js b/backend/src/server.js index c0c38a8..2bdb355 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,6 +1,9 @@ import { app } from './app.js'; import { env } from './config/env.js'; +import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js'; app.listen(env.port, () => { console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`); }); + +startRecurringTaskReminderJob(); diff --git a/frontend/package.json b/frontend/package.json index d1620d3..65a8593 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.11.0", + "version": "1.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 0d2a0c4..d840dfb 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -16,6 +16,7 @@ import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx'; import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx'; import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx'; import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx'; +import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx'; export default function App() { return ( @@ -33,6 +34,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index 690ca16..03a9f66 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, FolderKanban, Building2, Settings } from 'lucide-react'; +import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; import { useAuth } from '../context/auth-context.jsx'; import logoDark from '../assets/logoLinkedCorpTxt.png'; @@ -9,6 +9,7 @@ import logoIcon from '../assets/logoLinked1.png'; const ITEMS = [ { to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true }, { to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' }, + { to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' }, { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, { to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true }, ]; diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 08ad21a..3c77eb7 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -138,6 +138,26 @@ export const messages = { project_created_at: 'Creado', project_deleted_at: 'Eliminado', project_filter_due_soon: 'Solo próximos a vencer (7 días)', + + nav_recurring_tasks: 'Tareas recurrentes', + recurring_task_new: 'Nueva tarea recurrente', + recurring_task_project: 'Proyecto de origen', + recurring_task_every: 'Cada', + recurring_task_interval: 'Periodicidad', + recurring_task_interval_unit: 'Unidad', + recurring_task_unit_days: 'días', + recurring_task_unit_months: 'meses', + recurring_task_unit_years: 'años', + recurring_task_next_due: 'Próximo vencimiento', + recurring_task_reminder: 'Recordatorio', + recurring_task_reminder_days: 'Avisar con (días de anticipación)', + recurring_task_reminder_hour: 'Hora del recordatorio', + recurring_task_active: 'Activa', + recurring_task_complete_cycle: 'Completar ciclo', + recurring_task_complete_confirm: '¿Confirmas que ya completaste este ciclo? Se calcula el próximo vencimiento sumando la periodicidad configurada.', + recurring_task_promote: 'Convertir en recurrente', + recurring_task_promote_title: 'Convertir en tarea recurrente', + recurring_task_promote_hint: 'La tarea original de la actividad no se borra. Se crea una tarea recurrente nueva, enlazada a este proyecto, que avisa por correo cuando se acerque cada vencimiento.', project_delete_confirm_msg: '¿Seguro que deseas eliminar este proyecto? Se movera a la papelera y dejara de verse en el listado; un superadministrador puede restaurarlo o borrarlo definitivamente.', project_trash: 'Papelera', project_trash_title: 'Papelera de proyectos', @@ -348,6 +368,26 @@ export const messages = { project_created_at: 'Created', project_deleted_at: 'Deleted', project_filter_due_soon: 'Due soon only (7 days)', + + nav_recurring_tasks: 'Recurring tasks', + recurring_task_new: 'New recurring task', + recurring_task_project: 'Source project', + recurring_task_every: 'Every', + recurring_task_interval: 'Recurrence', + recurring_task_interval_unit: 'Unit', + recurring_task_unit_days: 'days', + recurring_task_unit_months: 'months', + recurring_task_unit_years: 'years', + recurring_task_next_due: 'Next due date', + recurring_task_reminder: 'Reminder', + recurring_task_reminder_days: 'Remind (days ahead)', + recurring_task_reminder_hour: 'Reminder hour', + recurring_task_active: 'Active', + recurring_task_complete_cycle: 'Complete cycle', + recurring_task_complete_confirm: 'Confirm you already completed this cycle? The next due date is calculated by adding the configured recurrence.', + recurring_task_promote: 'Turn into recurring', + recurring_task_promote_title: 'Turn into recurring task', + recurring_task_promote_hint: "The original task in the activity is not deleted. A new recurring task is created, linked to this project, that emails a reminder as each due date approaches.", project_delete_confirm_msg: 'Are you sure you want to delete this project? It will move to the trash and disappear from the list; a superadmin can restore it or delete it permanently.', project_trash: 'Trash', project_trash_title: 'Project trash', diff --git a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx index b0fc6b5..e96a2d9 100644 --- a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx +++ b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; -import { Pencil, Save, X, Plus, Trash2, Link2 } from 'lucide-react'; +import { Pencil, Save, X, Plus, Trash2, Link2, Repeat } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; import { updateActivity, addDependency, removeDependency } from '../../services/activities.js'; import { listTasks, createTask, setTaskDone, deleteTask } from '../../services/tasks.js'; +import { promoteTaskToRecurring } from '../../services/recurringTasks.js'; import { listCatalog } from '../../services/catalogs.js'; import { listUsers } from '../../services/users.js'; import { Button } from '../../components/Button.jsx'; @@ -13,6 +14,9 @@ import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx'; import { Tabs } from '../../components/Tabs.jsx'; import { CommentsPanel } from '../../components/CommentsPanel.jsx'; import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx'; +import { DetailPopup } from '../../components/DetailPopup.jsx'; + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` })); function toForm(a) { return { @@ -36,6 +40,8 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi const [form, setForm] = useState(() => toForm(activity)); const [newTask, setNewTask] = useState(''); const [addingDep, setAddingDep] = useState(''); + const [promoting, setPromoting] = useState(null); + const [promoteForm, setPromoteForm] = useState(null); useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]); @@ -74,6 +80,26 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi await runAction(async () => { await deleteTask(task.id); await reloadTasks(); onChanged(); }); } + function startPromote(task) { + setPromoting(task); + setPromoteForm({ + name: task.name, responsibleUserId: task.responsible_user_id || '', + intervalUnit: 'months', intervalValue: 1, nextDueDate: task.due_date || '', + reminderDaysBefore: 7, reminderHour: 9, + }); + } + + async function confirmPromote() { + if (!promoteForm.nextDueDate) return; + await runAction(async () => { + await promoteTaskToRecurring(promoting.id, { + ...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, + }); + setPromoting(null); + pushToast({ tone: 'success', message: t('common_saved') }); + }); + } + async function addDep() { if (!addingDep) return; await runAction(async () => { await addDependency(activity.id, Number(addingDep)); setAddingDep(''); onChanged(); }); @@ -184,6 +210,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
toggleTask(task)} /> {task.name} +
))} @@ -220,6 +247,59 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi {tab === 'comments' && } {tab === 'documents' && } + + setPromoting(null)} + width={560} + height={520} + > + {promoteForm && ( + <> +
{t('recurring_task_promote_hint')}
+ + setPromoteForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> + + + + setPromoteForm((f) => ({ ...f, responsibleUserId: v }))} /> + + + setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} /> + + + + + setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} /> + + + setPromoteForm((f) => ({ ...f, intervalUnit: v }))} + /> + + + + + setPromoteForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} /> + + + setPromoteForm((f) => ({ ...f, reminderHour: Number(v) }))} /> + + +
+ + +
+ + )} +
); } diff --git a/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx b/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx new file mode 100644 index 0000000..739117c --- /dev/null +++ b/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx @@ -0,0 +1,200 @@ +import { useState } from 'react'; +import { Plus, Save, Trash2, RotateCcw, Pencil } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAsync, useAction } from '../../hooks/useAsync.js'; +import { + listRecurringTasks, createRecurringTask, updateRecurringTask, deleteRecurringTask, + completeRecurringTaskCycle, +} from '../../services/recurringTasks.js'; +import { listUsers } from '../../services/users.js'; +import { listProjects } from '../../services/projects.js'; +import { ListView } from '../../components/ListView.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { Badge } from '../../components/Badge.jsx'; +import { DetailPopup } from '../../components/DetailPopup.jsx'; + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` })); + +const EMPTY_FORM = { + name: '', description: '', projectId: '', responsibleUserId: '', + intervalUnit: 'months', intervalValue: 1, nextDueDate: '', + reminderDaysBefore: 7, reminderHour: 9, +}; + +function toForm(r) { + return { + name: r.name, description: r.description || '', projectId: r.project_id || '', + responsibleUserId: r.responsible_user_id || '', intervalUnit: r.interval_unit, + intervalValue: r.interval_value, nextDueDate: r.next_due_date, + reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour, + }; +} + +export default function RecurringTasksPage() { + const { t, pushToast, openDialog } = useUi(); + const runAction = useAction(); + const { data: items, reload } = useAsync(listRecurringTasks, []); + const { data: users } = useAsync(listUsers, []); + const { data: projects } = useAsync(() => listProjects(), []); + + const [editing, setEditing] = useState(false); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + + const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name })); + const projectOptions = (projects || []).map((p) => ({ value: p.id, label: `${p.code} — ${p.name}` })); + const unitOptions = [ + { value: 'days', label: t('recurring_task_unit_days') }, + { value: 'months', label: t('recurring_task_unit_months') }, + { value: 'years', label: t('recurring_task_unit_years') }, + ]; + const unitLabel = { days: t('recurring_task_unit_days'), months: t('recurring_task_unit_months'), years: t('recurring_task_unit_years') }; + + function startNew() { + setSelected(null); + setForm(EMPTY_FORM); + setEditing(true); + } + + function startEdit(r) { + setSelected(r); + setForm(toForm(r)); + setEditing(true); + } + + async function save() { + if (!form.name.trim() || !form.nextDueDate) return; + await runAction(async () => { + const payload = { ...form, projectId: form.projectId || null, responsibleUserId: form.responsibleUserId || null }; + if (selected) await updateRecurringTask(selected.id, payload); + else await createRecurringTask(payload); + setEditing(false); + pushToast({ tone: 'success', message: t('common_saved') }); + await reload(); + }); + } + + function confirmDelete(r) { + openDialog({ + tone: 'warning', + title: t('common_confirm_delete_title'), + message: t('common_confirm_delete_msg'), + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('common_delete'), variant: 'danger', + onClick: () => runAction(async () => { await deleteRecurringTask(r.id); await reload(); }), + }, + ], + }); + } + + function confirmComplete(r) { + openDialog({ + tone: 'info', + title: t('recurring_task_complete_cycle'), + message: t('recurring_task_complete_confirm'), + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('recurring_task_complete_cycle'), variant: 'accent', + onClick: () => runAction(async () => { + await completeRecurringTaskCycle(r.id); + await reload(); + pushToast({ tone: 'success', message: t('common_saved') }); + }), + }, + ], + }); + } + + const columns = [ + { key: 'name', label: t('common_name') }, + { key: 'project_name', label: t('recurring_task_project'), render: (r) => r.project_name || '—' }, + { + key: 'interval_value', label: t('recurring_task_interval'), + render: (r) => `${t('recurring_task_every')} ${r.interval_value} ${unitLabel[r.interval_unit]}`, + }, + { key: 'next_due_date', label: t('recurring_task_next_due'), render: (r) => String(r.next_due_date).slice(0, 10) }, + { + key: 'reminder_hour', label: t('recurring_task_reminder'), + render: (r) => `${r.reminder_days_before}d / ${String(r.reminder_hour).padStart(2, '0')}:00`, + }, + { key: 'responsible_name', label: t('common_responsible'), render: (r) => r.responsible_name || '—' }, + { key: 'is_active', label: t('recurring_task_active'), render: (r) => {r.is_active ? t('common_yes') : t('common_no')} }, + ]; + + if (!items) return
{t('common_loading')}
; + + return ( +
+
+
+

{t('nav_recurring_tasks')}

+
+ +
+ + r.id} + onRowClick={startEdit} + initialSort={{ key: 'next_due_date', dir: 1 }} + rowActions={(r) => [ + { label: t('common_edit'), icon: Pencil, onClick: () => startEdit(r) }, + { label: t('recurring_task_complete_cycle'), icon: RotateCcw, onClick: () => confirmComplete(r) }, + { label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(r) }, + ]} + /> + + setEditing(false)} + width={640} + height={640} + > + + setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> + + +