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:
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user