feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
|
||||
import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject, listActivitiesTrash, restoreActivity } from '../../services/activities.js';
|
||||
import { listProjects } from '../../services/projects.js';
|
||||
import { listProjectOptions } from '../../services/projects.js';
|
||||
import { search as searchApi } from '../../services/search.js';
|
||||
import { listCatalog } from '../../services/catalogs.js';
|
||||
import { listUsers } from '../../services/users.js';
|
||||
@@ -20,10 +20,13 @@ import { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge
|
||||
import { dueStatus } from '../../utils/dueStatus.js';
|
||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
|
||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
|
||||
|
||||
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
|
||||
|
||||
export default function ProjectActivitiesTab({ project, projectId, openActivityId, openActivityTab }) {
|
||||
export default function ProjectActivitiesTab({ project, projectId }) {
|
||||
const { t, pushToast, openDialog } = useUi();
|
||||
const { user: currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -45,17 +48,59 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
const { data: allProjects } = useAsync(() => listProjects(), []);
|
||||
const { data: allProjects } = useAsync(listProjectOptions, []);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [selectedInitialTab, setSelectedInitialTab] = useState(undefined);
|
||||
const [moving, setMoving] = useState(null);
|
||||
const [targetProjectId, setTargetProjectId] = useState('');
|
||||
const [converting, setConverting] = useState(null);
|
||||
const [convertForm, setConvertForm] = useState({ projectName: '', taskActivityName: '' });
|
||||
const drafts = useUserDrafts();
|
||||
const guardedClose = useGuardedClose();
|
||||
|
||||
// "Nueva actividad" con borrador (por proyecto); se reabre al volver si quedo a medio llenar.
|
||||
const draft = useDraftForm({ formId: 'activity-new', entityId: projectId, initial: EMPTY_FORM, label: `${project.code} — ${t('activity_new')}`, enabled: creating });
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
const pendingNew = hasDraftFor(drafts, 'activity-new', projectId);
|
||||
useEffect(() => { if (pendingNew) setCreating(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// "Convertir en proyecto" con borrador (por actividad).
|
||||
const convertDraft = useDraftForm({
|
||||
formId: 'activity-convert', entityId: converting?.id,
|
||||
initial: { projectName: converting?.name || '', taskActivityName: converting?.name || '' },
|
||||
label: `${converting?.code || ''} — ${t('activity_convert_title')}`, enabled: !!converting,
|
||||
});
|
||||
const convertForm = convertDraft.values;
|
||||
const setConvertForm = convertDraft.setValues;
|
||||
|
||||
// Actividad abierta y su pestaña viven en la URL (?activity=&activityTab=): F5, el retorno tras
|
||||
// el login y el aviso de cambios sin guardar (al cerrar el popup o cambiar de pestaña) usan eso.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
|
||||
const activityTab = searchParams.get('activityTab') || 'detail';
|
||||
const setActivityParams = (id, tab) => setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (id) {
|
||||
next.set('activity', id);
|
||||
if (tab && tab !== 'detail') next.set('activityTab', tab); else next.delete('activityTab');
|
||||
} else {
|
||||
next.delete('activity');
|
||||
next.delete('activityTab');
|
||||
}
|
||||
return next;
|
||||
}, { replace: true });
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setSelected(null); return undefined; }
|
||||
let alive = true;
|
||||
// Si la actividad ya no existe o no hay acceso, no se abre (ni se restauran sus borradores).
|
||||
getActivity(selectedId)
|
||||
.then((a) => { if (alive) setSelected(a); })
|
||||
.catch(() => { if (alive) setActivityParams(null); });
|
||||
return () => { alive = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId]);
|
||||
|
||||
// Busca tambien tareas de esta actividad (no solo actividades, que ya filtra la tabla mas abajo
|
||||
// via getSearchHaystack) — resultados con su ruta "Actividad - Tarea" en un panel flotante.
|
||||
@@ -78,14 +123,14 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
})),
|
||||
] : null;
|
||||
|
||||
// Llegada desde el buscador global de Proyectos: abre directo la actividad indicada (una sola vez).
|
||||
const openedFromDeepLink = useRef(false);
|
||||
// Al volver con un borrador de "Convertir en proyecto", se reabre sobre su actividad.
|
||||
useEffect(() => {
|
||||
if (openedFromDeepLink.current || !openActivityId || !activities) return;
|
||||
openedFromDeepLink.current = true;
|
||||
openActivity({ id: openActivityId }, openActivityTab);
|
||||
if (!activities || converting) return;
|
||||
const pending = drafts.find((d) => d.formId === 'activity-convert');
|
||||
const row = pending && activities.find((a) => String(a.id) === String(pending.entityId));
|
||||
if (row) setConverting(row);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openActivityId, activities]);
|
||||
}, [activities]);
|
||||
|
||||
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
||||
@@ -98,17 +143,15 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
|
||||
await runAction(async () => {
|
||||
await createActivity(projectId, { ...form, responsibleUserId: form.responsibleUserId || null });
|
||||
draft.clear();
|
||||
setCreating(false);
|
||||
setForm(EMPTY_FORM);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
await reload();
|
||||
});
|
||||
}
|
||||
|
||||
async function openActivity(row, initialTab) {
|
||||
const full = await getActivity(row.id);
|
||||
setSelected(full);
|
||||
setSelectedInitialTab(initialTab);
|
||||
function openActivity(row, initialTab) {
|
||||
setActivityParams(row.id, initialTab);
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
@@ -151,7 +194,6 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
|
||||
function startConvert(row) {
|
||||
setConverting(row);
|
||||
setConvertForm({ projectName: row.name, taskActivityName: row.name });
|
||||
}
|
||||
|
||||
function confirmConvert() {
|
||||
@@ -171,6 +213,7 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
label: t('common_accept'), variant: 'accent',
|
||||
onClick: () => runAction(async () => {
|
||||
const result = await convertActivityToProject(converting.id, projectName.trim(), taskActivityName.trim());
|
||||
convertDraft.clear();
|
||||
setConverting(null);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
navigate(`/proyectos/${result.project.id}`);
|
||||
@@ -229,7 +272,7 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
{project?.isOwner && (
|
||||
<Button variant="standard" icon={Archive} onClick={() => setTrashOpen(true)}>{t('activities_trash')}</Button>
|
||||
)}
|
||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
|
||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}{pendingNew && !creating && <DraftMark />}</Button>
|
||||
</div>
|
||||
|
||||
<DetailPopup open={trashOpen} title={t('activities_trash')} onClose={() => setTrashOpen(false)} width={480} height={420}>
|
||||
@@ -271,17 +314,18 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
)}
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={!!converting} title={t('activity_convert_title')} onClose={() => setConverting(null)} width={520} height={360}>
|
||||
<DetailPopup open={!!converting} title={t('activity_convert_title')} onClose={() => guardedClose(convertDraft, () => setConverting(null))} width={520} height={400}>
|
||||
{converting && (
|
||||
<>
|
||||
<DraftNotice draft={convertDraft} />
|
||||
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 12 }}>{t('activity_convert_hint')}</div>
|
||||
<Field label={t('activity_convert_project_name')}>
|
||||
<Field label={t('activity_convert_project_name')} dirty={convertDraft.isFieldDirty('projectName')}>
|
||||
<input
|
||||
className="input" value={convertForm.projectName} autoFocus
|
||||
onChange={(e) => setConvertForm((f) => ({ ...f, projectName: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('activity_convert_activity_name')}>
|
||||
<Field label={t('activity_convert_activity_name')} dirty={convertDraft.isFieldDirty('taskActivityName')}>
|
||||
<input
|
||||
className="input" value={convertForm.taskActivityName}
|
||||
onChange={(e) => setConvertForm((f) => ({ ...f, taskActivityName: e.target.value }))}
|
||||
@@ -294,38 +338,39 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
>
|
||||
{t('activity_convert')}
|
||||
</Button>
|
||||
<Button variant="standard" onClick={() => setConverting(null)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { convertDraft.discard(); setConverting(null); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
|
||||
<Field label={t('common_name')}>
|
||||
<DetailPopup open={creating} title={t('activity_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={640} height={600}>
|
||||
<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')}>
|
||||
<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('common_responsible')}>
|
||||
<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>
|
||||
<Field label={t('common_priority')}>
|
||||
<Field label={t('common_priority')} dirty={draft.isFieldDirty('priorityId')}>
|
||||
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_status')}>
|
||||
<Field label={t('common_status')} dirty={draft.isFieldDirty('statusId')}>
|
||||
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('activity_start_date')}>
|
||||
<Field label={t('activity_start_date')} dirty={draft.isFieldDirty('startDate')}>
|
||||
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={t('activity_due_date')}>
|
||||
<Field label={t('activity_due_date')} dirty={draft.isFieldDirty('dueDate')}>
|
||||
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={t('activity_milestone')}>
|
||||
<Field label={t('activity_milestone')} dirty={draft.isFieldDirty('isMilestone')}>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
|
||||
{t('activity_milestone')}
|
||||
@@ -334,18 +379,20 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={() => setCreating(false)}>{t('common_cancel')}</Button>
|
||||
<Button variant="standard" onClick={() => { draft.discard(); setCreating(false); }}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setSelected(null)} width={720} height={680}>
|
||||
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setActivityParams(null)} width={720} height={680}>
|
||||
{selected && (
|
||||
<ActivityDetailPanel
|
||||
key={selected.id}
|
||||
activity={selected}
|
||||
projectId={projectId}
|
||||
siblingActivities={activities}
|
||||
onChanged={refreshAll}
|
||||
initialTab={selectedInitialTab}
|
||||
tab={activityTab}
|
||||
onTabChange={(key) => setActivityParams(selected.id, key)}
|
||||
/>
|
||||
)}
|
||||
</DetailPopup>
|
||||
|
||||
Reference in New Issue
Block a user