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 /> + + +