feat: Tareas recurrentes con recordatorio por correo

Menu propio en la barra lateral, independiente de Proyectos:
- Periodicidad configurable (cada N dias/meses/años), proximo
  vencimiento, responsable, proyecto de origen opcional.
- "Completar ciclo" avanza el proximo vencimiento sumando la
  periodicidad y limpia el recordatorio ya enviado.
- Recordatorio por correo automatico N dias antes del vencimiento
  (default 7), a una hora del dia configurable. Un job en el propio
  proceso del backend (setInterval cada 15 min) revisa y envia, sin
  infraestructura nueva (cron del SO, cola, etc).
- Desde la pestaña Tareas de una actividad, cada tarea tiene un boton
  "Convertir en recurrente" que la promueve (la tarea original no se
  borra).

Probado en dev con curl (crear, completar ciclo avanza next_due_date,
promover desde una tarea real, disparo real del job de recordatorios
verificado forzando fecha/hora, segunda corrida no reenvia) y Playwright
(listado, popup de creacion, popup de promocion anidado sobre el detalle
de actividad) sin errores de consola.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-11 10:17:02 -05:00
parent 02e5590b42
commit 72b616bd02
20 changed files with 669 additions and 5 deletions
@@ -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>
);
}