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:
@@ -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
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
@@ -220,6 +247,59 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
|
||||
{tab === 'comments' && <CommentsPanel 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user