Files
project.linkedcorp.net/frontend/src/pages/RecurringTasks/RecurringTasksPage.jsx
T
Milton Gonzalez d562351242 feat: version 4.5.0 -- Calendario personal con filtros y capas por tipo
Calendario personal para todos los roles: reuniones que organizo o me invitaron,
proyectos donde soy dueno/miembro, actividades y tareas donde soy responsable y
tareas recurrentes creadas o compartidas conmigo (solo lectura si no soy el creador).
Filtros Ver (Todo / Solo lo mio / Compartido conmigo) y Mostrar (lista de tipos con
Todo); colores por tipo, vencidos en rojo, compartido marcado con franja.
La pestana Calendario del proyecto muestra todas sus reuniones vinculadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:23:29 -05:00

236 lines
11 KiB
React

import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
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 { listProjectOptions } 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';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
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: '', endDate: '',
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, endDate: r.end_date || '',
reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour, editVersion: r.edit_version,
};
}
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(listProjectOptions, []);
const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null);
const [searchParams, setSearchParams] = useSearchParams();
// Borrador del popup (nueva o edicion de una existente).
const draft = useDraftForm({
formId: selected ? 'recurring-edit' : 'recurring-new', entityId: selected?.id ?? null,
initial: selected ? toForm(selected) : EMPTY_FORM,
label: selected ? `${t('common_edit')}: ${selected.name}` : t('recurring_task_new'), enabled: editing,
});
const form = draft.values;
const setForm = draft.setValues;
const guardedClose = useGuardedClose();
const pendingNew = hasDraftFor(useUserDrafts(), 'recurring-new', null);
useReopenDraft({
newFormId: 'recurring-new', editFormId: 'recurring-edit', items,
openNew: () => startNew(), openEdit: (r) => startEdit(r),
});
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') };
// Llegada desde el Calendario (?task=id): abre esa tarea como un clic en su fila.
useEffect(() => {
const id = Number(searchParams.get('task'));
if (!id || !items) return;
const found = items.find((r) => r.id === id);
if (found) startEdit(found);
setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('task'); return next; }, { replace: true });
}, [searchParams, items]); // eslint-disable-line react-hooks/exhaustive-deps
function startNew() {
setSelected(null);
setEditing(true);
}
function startEdit(r) {
setSelected(r);
setEditing(true);
}
async function save() {
if (!form.name.trim() || !form.nextDueDate) return;
const run = () => runAction(async () => {
const payload = { ...form, projectId: form.projectId || null, responsibleUserId: form.responsibleUserId || null, endDate: form.endDate || null };
if (selected) await updateRecurringTask(selected.id, payload);
else await createRecurringTask(payload);
draft.clear();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
await reload();
});
// Edicion: si el registro cambio en el servidor desde que se empezo a editar, se confirma.
if (selected) await draft.guardSave(run); else await run();
}
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: 'end_date', label: t('recurring_task_end_date'), render: (r) => r.end_date ? String(r.end_date).slice(0, 10) : t('recurring_task_unlimited') },
{
key: 'reminder_hour', label: t('recurring_task_reminder'),
render: (r) => `${r.reminder_days_before}d / ${String(r.reminder_hour).padStart(2, '0')}:00`,
},
{ 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')}{pendingNew && !editing && <DraftMark />}</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={() => guardedClose(draft, () => setEditing(false))}
width={640}
height={760}
>
<DraftNotice draft={draft} />
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('recurring_task_project')} dirty={draft.isFieldDirty('projectId')}>
<ComboBox options={[{ value: '', label: '—' }, ...projectOptions]} value={form.projectId} onChange={(v) => setForm((f) => ({ ...f, projectId: v }))} />
</Field>
<Field label={t('common_responsible')} dirty={draft.isFieldDirty('responsibleUserId')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_every')} dirty={draft.isFieldDirty('intervalValue')}>
<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')} dirty={draft.isFieldDirty('intervalUnit')}>
<ComboBox options={unitOptions} value={form.intervalUnit} onChange={(v) => setForm((f) => ({ ...f, intervalUnit: v }))} />
</Field>
<Field label={t('recurring_task_next_due')} dirty={draft.isFieldDirty('nextDueDate')}>
<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_end_date')} hint={t('recurring_task_unlimited_hint')} dirty={draft.isFieldDirty('endDate')}>
<input className="input" type="date" value={form.endDate} onChange={(e) => setForm((f) => ({ ...f, endDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_reminder_days')} dirty={draft.isFieldDirty('reminderDaysBefore')}>
<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')} dirty={draft.isFieldDirty('reminderHour')}>
<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={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
);
}