d562351242
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>
236 lines
11 KiB
React
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>
|
|
);
|
|
}
|