feat: Tareas recurrentes con recordatorio por correo

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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-11 10:17:02 -05:00
parent 02e5590b42
commit 72b616bd02
20 changed files with 669 additions and 5 deletions
+13
View File
@@ -76,6 +76,19 @@
### Tareas (`/api/activities/:activityId/tasks`) ### Tareas (`/api/activities/:activityId/tasks`)
- `GET` · `POST` · `PUT /api/tasks/:id` · `PATCH /api/tasks/:id/done` · `DELETE /api/tasks/:id` - `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`) ### Comentarios (`/api/comments`)
- `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments` - `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments`
+8
View File
@@ -33,6 +33,7 @@
| `activities` | actividades de un proyecto | | `activities` | actividades de un proyecto |
| `activity_dependencies` | dependencias simples entre actividades ("depende de") | | `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) | | `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`) | | `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) |
| `attachments` | archivos adjuntos a proyecto o actividad | | `attachments` | archivos adjuntos a proyecto o actividad |
| `notifications` | notificaciones in-app por usuario | | `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 `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 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. 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 - `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`). `.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 - Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con
+11
View File
@@ -1,5 +1,16 @@
# Changelog - project.linkedcorp.net # 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 ## [1.11.0] - 2026-09-11
### Added ### Added
- Filtros en el listado de Proyectos: Estado, Prioridad (ambos ya soportados por el backend, solo - Filtros en el listado de Proyectos: Estado, Prioridad (ambos ya soportados por el backend, solo
+1 -1
View File
@@ -1 +1 @@
1.11.0 1.12.0
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "1.11.0", "version": "1.12.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+2
View File
@@ -17,6 +17,7 @@ import notificationsRoutes from './modules/notifications/routes.js';
import dashboardRoutes from './modules/dashboard/routes.js'; import dashboardRoutes from './modules/dashboard/routes.js';
import auditRoutes from './modules/audit/routes.js'; import auditRoutes from './modules/audit/routes.js';
import settingsRoutes from './modules/settings/routes.js'; import settingsRoutes from './modules/settings/routes.js';
import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
export const app = express(); export const app = express();
@@ -38,6 +39,7 @@ app.use('/api/notifications', notificationsRoutes);
app.use('/api/dashboard', dashboardRoutes); app.use('/api/dashboard', dashboardRoutes);
app.use('/api/audit', auditRoutes); app.use('/api/audit', auditRoutes);
app.use('/api/settings', settingsRoutes); 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 // 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 // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica). // especifico registrado despues (ver ESTADO.md, deuda tecnica).
@@ -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);
}
@@ -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);
});
@@ -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]);
}
@@ -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;
@@ -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: `<p>Hola ${recipientName},</p>
<p>La tarea recurrente <strong>${item.name}</strong>${item.project_name ? ` (proyecto: ${item.project_name})` : ''}
vence el <strong>${item.next_due_date}</strong>.</p>
<p>Entra a project.linkedcorp.net → Tareas recurrentes para revisarla.</p>`,
});
} catch (err) {
console.error('[recurring-tasks] error enviando recordatorio', item.id, err.message);
}
await repo.markReminderSent(item.id);
}
return due.length;
}
+3
View File
@@ -1,6 +1,9 @@
import { app } from './app.js'; import { app } from './app.js';
import { env } from './config/env.js'; import { env } from './config/env.js';
import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js';
app.listen(env.port, () => { app.listen(env.port, () => {
console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`); console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`);
}); });
startRecurringTaskReminderJob();
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "1.11.0", "version": "1.12.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -16,6 +16,7 @@ import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx';
import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx'; import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx';
import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx'; import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx';
import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx'; import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx';
import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx';
export default function App() { export default function App() {
return ( return (
@@ -33,6 +34,7 @@ export default function App() {
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="proyectos" element={<ProyectosPage />} /> <Route path="proyectos" element={<ProyectosPage />} />
<Route path="proyectos/:id" element={<ProyectoDetallePage />} /> <Route path="proyectos/:id" element={<ProyectoDetallePage />} />
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
<Route path="organizacion" element={<OrganizacionPage />} /> <Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} /> <Route path="configuracion" element={<ConfiguracionPage />} />
</Route> </Route>
+2 -1
View File
@@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'; 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 { useUi } from '../context/ui-context.jsx';
import { useAuth } from '../context/auth-context.jsx'; import { useAuth } from '../context/auth-context.jsx';
import logoDark from '../assets/logoLinkedCorpTxt.png'; import logoDark from '../assets/logoLinkedCorpTxt.png';
@@ -9,6 +9,7 @@ import logoIcon from '../assets/logoLinked1.png';
const ITEMS = [ const ITEMS = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true }, { to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' }, { to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true }, { to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
]; ];
+40
View File
@@ -138,6 +138,26 @@ export const messages = {
project_created_at: 'Creado', project_created_at: 'Creado',
project_deleted_at: 'Eliminado', project_deleted_at: 'Eliminado',
project_filter_due_soon: 'Solo próximos a vencer (7 días)', 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_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: 'Papelera',
project_trash_title: 'Papelera de proyectos', project_trash_title: 'Papelera de proyectos',
@@ -348,6 +368,26 @@ export const messages = {
project_created_at: 'Created', project_created_at: 'Created',
project_deleted_at: 'Deleted', project_deleted_at: 'Deleted',
project_filter_due_soon: 'Due soon only (7 days)', 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_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: 'Trash',
project_trash_title: 'Project trash', project_trash_title: 'Project trash',
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'; 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 { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js'; import { useAsync, useAction } from '../../hooks/useAsync.js';
import { updateActivity, addDependency, removeDependency } from '../../services/activities.js'; import { updateActivity, addDependency, removeDependency } from '../../services/activities.js';
import { listTasks, createTask, setTaskDone, deleteTask } from '../../services/tasks.js'; import { listTasks, createTask, setTaskDone, deleteTask } from '../../services/tasks.js';
import { promoteTaskToRecurring } from '../../services/recurringTasks.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';
import { Button } from '../../components/Button.jsx'; import { Button } from '../../components/Button.jsx';
@@ -13,6 +14,9 @@ import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
import { Tabs } from '../../components/Tabs.jsx'; 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';
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` }));
function toForm(a) { function toForm(a) {
return { return {
@@ -36,6 +40,8 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
const [form, setForm] = useState(() => toForm(activity)); const [form, setForm] = useState(() => toForm(activity));
const [newTask, setNewTask] = useState(''); const [newTask, setNewTask] = useState('');
const [addingDep, setAddingDep] = useState(''); const [addingDep, setAddingDep] = useState('');
const [promoting, setPromoting] = useState(null);
const [promoteForm, setPromoteForm] = useState(null);
useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]); 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(); }); 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() { async function addDep() {
if (!addingDep) return; if (!addingDep) return;
await runAction(async () => { await addDependency(activity.id, Number(addingDep)); setAddingDep(''); onChanged(); }); await runAction(async () => { await addDependency(activity.id, Number(addingDep)); setAddingDep(''); onChanged(); });
@@ -184,6 +210,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
<div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}> <div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
<input type="checkbox" checked={!!task.is_done} onChange={() => toggleTask(task)} /> <input type="checkbox" checked={!!task.is_done} onChange={() => toggleTask(task)} />
<span style={{ flex: 1, fontSize: '.8rem', textDecoration: task.is_done ? 'line-through' : 'none', opacity: task.is_done ? .6 : 1 }}>{task.name}</span> <span style={{ flex: 1, fontSize: '.8rem', textDecoration: task.is_done ? 'line-through' : 'none', opacity: task.is_done ? .6 : 1 }}>{task.name}</span>
<button className="icon-btn" title={t('recurring_task_promote')} onClick={() => startPromote(task)}><Repeat size={15} /></button>
<button className="icon-btn" onClick={() => removeTaskItem(task)}><Trash2 size={15} /></button> <button className="icon-btn" onClick={() => removeTaskItem(task)}><Trash2 size={15} /></button>
</div> </div>
))} ))}
@@ -220,6 +247,59 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
{tab === 'comments' && <CommentsPanel entityType="activity" entityId={activity.id} />} {tab === 'comments' && <CommentsPanel entityType="activity" entityId={activity.id} />}
{tab === 'documents' && <AttachmentsPanel entityType="activity" entityId={activity.id} />} {tab === 'documents' && <AttachmentsPanel entityType="activity" entityId={activity.id} />}
<DetailPopup
open={!!promoting}
title={t('recurring_task_promote_title')}
onClose={() => setPromoting(null)}
width={560}
height={520}
>
{promoteForm && (
<>
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>{t('recurring_task_promote_hint')}</div>
<Field label={t('common_name')}>
<input className="input" value={promoteForm.name} onChange={(e) => setPromoteForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<FieldRow>
<Field label={t('common_responsible')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={promoteForm.responsibleUserId} onChange={(v) => setPromoteForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
<Field label={t('recurring_task_next_due')}>
<input className="input" type="date" value={promoteForm.nextDueDate} onChange={(e) => setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_every')}>
<input className="input" type="number" min={1} value={promoteForm.intervalValue} onChange={(e) => setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_interval_unit')}>
<ComboBox
options={[
{ value: 'days', label: t('recurring_task_unit_days') },
{ value: 'months', label: t('recurring_task_unit_months') },
{ value: 'years', label: t('recurring_task_unit_years') },
]}
value={promoteForm.intervalUnit}
onChange={(v) => setPromoteForm((f) => ({ ...f, intervalUnit: v }))}
/>
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_reminder_days')}>
<input className="input" type="number" min={0} value={promoteForm.reminderDaysBefore} onChange={(e) => setPromoteForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_reminder_hour')}>
<ComboBox options={HOUR_OPTIONS} value={promoteForm.reminderHour} onChange={(v) => setPromoteForm((f) => ({ ...f, reminderHour: Number(v) }))} />
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={Repeat} onClick={confirmPromote}>{t('recurring_task_promote')}</Button>
<Button variant="standard" onClick={() => setPromoting(null)}>{t('common_cancel')}</Button>
</div>
</>
)}
</DetailPopup>
</div> </div>
); );
} }
@@ -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) => <Badge color={r.is_active ? 'success' : 'muted'}>{r.is_active ? t('common_yes') : t('common_no')}</Badge> },
];
if (!items) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div className="toolbar">
<div className="toolbar-left">
<h2 className="page-title mt-0">{t('nav_recurring_tasks')}</h2>
</div>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('recurring_task_new')}</Button>
</div>
<ListView
columns={columns}
rows={items}
getRowKey={(r) => 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) },
]}
/>
<DetailPopup
open={editing}
title={selected ? t('common_edit') : t('recurring_task_new')}
onClose={() => setEditing(false)}
width={640}
height={640}
>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('common_description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('recurring_task_project')}>
<ComboBox options={[{ value: '', label: '—' }, ...projectOptions]} value={form.projectId} onChange={(v) => setForm((f) => ({ ...f, projectId: v }))} />
</Field>
<Field label={t('common_responsible')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_every')}>
<input className="input" type="number" min={1} value={form.intervalValue} onChange={(e) => setForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_interval_unit')}>
<ComboBox options={unitOptions} value={form.intervalUnit} onChange={(v) => setForm((f) => ({ ...f, intervalUnit: v }))} />
</Field>
<Field label={t('recurring_task_next_due')}>
<input className="input" type="date" value={form.nextDueDate} onChange={(e) => setForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_reminder_days')}>
<input className="input" type="number" min={0} value={form.reminderDaysBefore} onChange={(e) => setForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_reminder_hour')}>
<ComboBox options={HOUR_OPTIONS} value={form.reminderHour} onChange={(v) => setForm((f) => ({ ...f, reminderHour: Number(v) }))} />
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { api } from './api.js';
export const listRecurringTasks = () => api.get('/recurring-tasks');
export const createRecurringTask = (data) => api.post('/recurring-tasks', data);
export const updateRecurringTask = (id, data) => api.put(`/recurring-tasks/${id}`, data);
export const deleteRecurringTask = (id) => api.delete(`/recurring-tasks/${id}`);
export const completeRecurringTaskCycle = (id) => api.post(`/recurring-tasks/${id}/complete`);
export const promoteTaskToRecurring = (taskId, data) => api.post(`/recurring-tasks/from-task/${taskId}`, data);