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,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "1.11.0",
|
||||
"version": "1.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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() {
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="proyectos" element={<ProyectosPage />} />
|
||||
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
|
||||
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
|
||||
<Route path="organizacion" element={<OrganizacionPage />} />
|
||||
<Route path="configuracion" element={<ConfiguracionPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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