diff --git a/API.md b/API.md index 0ef71aa..1db188e 100644 --- a/API.md +++ b/API.md @@ -83,7 +83,8 @@ - `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 }`. + intervalValue, nextDueDate, endDate?, reminderDaysBefore, reminderHour: 0-23 }`. `endDate` vacío + = se repite sin fin; al completar un ciclo que la supera, `is_active` pasa a `false` solo. - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index eb98569..08f2a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog - project.linkedcorp.net +## [1.12.1] - 2026-09-11 +### Added +- Tareas recurrentes: campo opcional "Hasta" (fecha de fin). Vacío = se repite sin fin (como + hasta ahora). Al completar un ciclo cuyo próximo vencimiento supera "Hasta", la tarea se + desactiva sola — no requiere acción manual. + ## [1.12.0] - 2026-09-11 ### Added - **Tareas recurrentes**: menú propio en la barra lateral, independiente de Proyectos. Cada tarea diff --git a/VERSION b/VERSION index 0eed1a2..f8f4f03 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.0 +1.12.1 diff --git a/backend/database/migrations/011_recurring_tasks_end_date.sql b/backend/database/migrations/011_recurring_tasks_end_date.sql new file mode 100644 index 0000000..450fab7 --- /dev/null +++ b/backend/database/migrations/011_recurring_tasks_end_date.sql @@ -0,0 +1,3 @@ +-- "Hasta cuando" opcional para Tareas recurrentes: vacio = indefinida. +ALTER TABLE recurring_tasks + ADD COLUMN IF NOT EXISTS end_date DATE NULL AFTER next_due_date; diff --git a/backend/package.json b/backend/package.json index 4c89fb8..a161e8a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.12.0", + "version": "1.12.1", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/recurring-tasks/repository.js b/backend/src/modules/recurring-tasks/repository.js index f6a5d4a..56401ff 100644 --- a/backend/src/modules/recurring-tasks/repository.js +++ b/backend/src/modules/recurring-tasks/repository.js @@ -3,7 +3,7 @@ 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.interval_unit, r.interval_value, r.next_due_date, r.end_date, r.reminder_days_before, r.reminder_hour, r.is_active, r.last_reminder_sent_at, r.created_by, r.created_at, r.updated_at FROM recurring_tasks r @@ -25,11 +25,11 @@ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + next_due_date, end_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.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null, data.reminderDaysBefore ?? 7, data.reminderHour ?? 9, data.isActive === false ? 0 : 1, data.createdBy, ] @@ -41,11 +41,11 @@ 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 = ? + interval_value = ?, next_due_date = ?, end_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.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null, data.reminderDaysBefore ?? 7, data.reminderHour ?? 9, data.isActive === false ? 0 : 1, id, ] @@ -60,7 +60,8 @@ export async function remove(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). +// (para que el proximo ciclo pueda volver a mandar su propio aviso). Si el nuevo vencimiento supera +// "hasta cuando" (end_date), la tarea se desactiva sola — ya cumplio su ultimo ciclo. 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]; @@ -72,6 +73,11 @@ export async function advanceCycle(id) { WHERE id = ?`, [row.interval_value, id] ); + await pool.query( + `UPDATE recurring_tasks SET is_active = 0 + WHERE id = ? AND end_date IS NOT NULL AND next_due_date > end_date`, + [id] + ); return findById(id); } @@ -89,6 +95,7 @@ export async function findDueForReminder() { JOIN users cu ON cu.id = r.created_by WHERE r.is_active = 1 AND r.last_reminder_sent_at IS NULL + AND (r.end_date IS NULL OR r.next_due_date <= r.end_date) AND DATE_SUB(r.next_due_date, INTERVAL r.reminder_days_before DAY) = CURDATE() AND HOUR(NOW()) = r.reminder_hour` ); diff --git a/backend/src/modules/recurring-tasks/service.js b/backend/src/modules/recurring-tasks/service.js index 750d053..fb9fc08 100644 --- a/backend/src/modules/recurring-tasks/service.js +++ b/backend/src/modules/recurring-tasks/service.js @@ -15,6 +15,9 @@ function validate(payload) { if (payload.reminderHour !== undefined && (payload.reminderHour < 0 || payload.reminderHour > 23)) { throw new HttpError(400, 'La hora del recordatorio debe estar entre 0 y 23'); } + if (payload.endDate && payload.endDate < payload.nextDueDate) { + throw new HttpError(400, '"Hasta" no puede ser anterior al proximo vencimiento'); + } } export const listRecurringTasks = () => repo.findAll(); @@ -69,6 +72,7 @@ export async function promoteFromTask(taskId, payload, currentUser) { intervalUnit: payload.intervalUnit, intervalValue: payload.intervalValue, nextDueDate: payload.nextDueDate, + endDate: payload.endDate || null, reminderDaysBefore: payload.reminderDaysBefore, reminderHour: payload.reminderHour, }; diff --git a/frontend/package.json b/frontend/package.json index 65a8593..fe10e9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.12.0", + "version": "1.12.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 3c77eb7..4207464 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -153,6 +153,9 @@ export const messages = { recurring_task_reminder_days: 'Avisar con (días de anticipación)', recurring_task_reminder_hour: 'Hora del recordatorio', recurring_task_active: 'Activa', + recurring_task_end_date: 'Hasta', + recurring_task_unlimited: 'Indefinida', + recurring_task_unlimited_hint: 'Vacío = se repite sin fin. Al pasar esta fecha, la tarea se desactiva sola.', 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', @@ -383,6 +386,9 @@ export const messages = { recurring_task_reminder_days: 'Remind (days ahead)', recurring_task_reminder_hour: 'Reminder hour', recurring_task_active: 'Active', + recurring_task_end_date: 'Until', + recurring_task_unlimited: 'Unlimited', + recurring_task_unlimited_hint: 'Empty = repeats forever. Once past this date, the task deactivates itself.', 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', diff --git a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx index e96a2d9..ae7e18c 100644 --- a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx +++ b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx @@ -84,7 +84,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi setPromoting(task); setPromoteForm({ name: task.name, responsibleUserId: task.responsible_user_id || '', - intervalUnit: 'months', intervalValue: 1, nextDueDate: task.due_date || '', + intervalUnit: 'months', intervalValue: 1, nextDueDate: task.due_date || '', endDate: '', reminderDaysBefore: 7, reminderHour: 9, }); } @@ -93,7 +93,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi if (!promoteForm.nextDueDate) return; await runAction(async () => { await promoteTaskToRecurring(promoting.id, { - ...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, + ...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, endDate: promoteForm.endDate || null, }); setPromoting(null); pushToast({ tone: 'success', message: t('common_saved') }); @@ -253,7 +253,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi title={t('recurring_task_promote_title')} onClose={() => setPromoting(null)} width={560} - height={520} + height={600} > {promoteForm && ( <> @@ -269,6 +269,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} /> + + + setPromoteForm((f) => ({ ...f, endDate: e.target.value }))} /> + + setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} /> diff --git a/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx b/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx index 739117c..08b7cc6 100644 --- a/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx +++ b/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx @@ -19,7 +19,7 @@ const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `$ const EMPTY_FORM = { name: '', description: '', projectId: '', responsibleUserId: '', - intervalUnit: 'months', intervalValue: 1, nextDueDate: '', + intervalUnit: 'months', intervalValue: 1, nextDueDate: '', endDate: '', reminderDaysBefore: 7, reminderHour: 9, }; @@ -27,7 +27,7 @@ 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, + intervalValue: r.interval_value, nextDueDate: r.next_due_date, endDate: r.end_date || '', reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour, }; } @@ -67,7 +67,7 @@ export default function RecurringTasksPage() { async function save() { if (!form.name.trim() || !form.nextDueDate) return; await runAction(async () => { - const payload = { ...form, projectId: form.projectId || null, responsibleUserId: form.responsibleUserId || null }; + const payload = { ...form, projectId: form.projectId || null, responsibleUserId: form.responsibleUserId || null, endDate: form.endDate || null }; if (selected) await updateRecurringTask(selected.id, payload); else await createRecurringTask(payload); setEditing(false); @@ -118,6 +118,7 @@ export default function RecurringTasksPage() { 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: 'end_date', label: t('recurring_task_end_date'), render: (r) => r.end_date ? String(r.end_date).slice(0, 10) : t('recurring_task_unlimited') }, { key: 'reminder_hour', label: t('recurring_task_reminder'), render: (r) => `${r.reminder_days_before}d / ${String(r.reminder_hour).padStart(2, '0')}:00`, @@ -155,7 +156,7 @@ export default function RecurringTasksPage() { title={selected ? t('common_edit') : t('recurring_task_new')} onClose={() => setEditing(false)} width={640} - height={640} + height={720} > setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> @@ -182,6 +183,11 @@ export default function RecurringTasksPage() { setForm((f) => ({ ...f, nextDueDate: e.target.value }))} /> + + + setForm((f) => ({ ...f, endDate: e.target.value }))} /> + + setForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />