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:
Milton Gonzalez
2026-10-23 11:36:02 -05:00
parent 8ed1d63505
commit e65111a7f4
112 changed files with 2285 additions and 651 deletions
+49 -34
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Save, Trash2, RotateCcw, ArrowLeft } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
@@ -19,6 +19,9 @@ import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.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 } from '../../hooks/useDraftForm.js';
import { SharedWithIcon } from '../../components/SharedWithIcon.jsx';
const CAN_DELETE_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
@@ -50,6 +53,8 @@ export default function ProyectosPage() {
const [filterStatusId, setFilterStatusId] = useState('');
const [filterPriorityId, setFilterPriorityId] = useState('');
const [filterDueSoon, setFilterDueSoon] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// '' = todos, 'hide' = ocultar 100%, 'only' = solo 100%. Persistido en el navegador (sobrevive
// al cierre de sesion). Respeta el checkbox anterior "Ocultar 100%" (clave vieja) si existia.
const [progressFilter, setProgressFilter] = useState(() => {
@@ -67,25 +72,36 @@ export default function ProyectosPage() {
// Prueba exploratoria: filtro "ver todos / por usuario" solo para roles que ya ven todos los
// proyectos hoy (superadmin/admin/it_manager) -- backend ya soporta ownerUserId, sin cambios.
const canFilterByUser = ['superadmin', 'admin', 'it_manager'].includes(currentUser?.roleCode);
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search, 300);
const { data: projects, reload } = useAsync(
() => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined }),
[showTrash, filterStatusId, filterPriorityId, filterOwnerUserId]
const { data: projectPage, reload } = useAsync(
() => listProjects(showTrash ? { trash: true, page, pageSize, q: debouncedSearch } : {
statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined,
dueSoon: filterDueSoon, progress: progressFilter, q: debouncedSearch, page, pageSize,
}),
[showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, debouncedSearch, page, pageSize]
);
const projects = projectPage?.items;
const { data: allUsers } = useAsync(listUsers, []);
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
const { data: types } = useAsync(() => listCatalog('project-types'), []);
const { data: deptTree } = useAsync(listDepartments, []);
const [search, setSearch] = useState('');
useEffect(() => { setPage(1); }, [showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, search, pageSize]);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
// "Nuevo proyecto" con borrador: si se sale a medio llenar, al volver se reabre el popup.
const draft = useDraftForm({ formId: 'project-new', entityId: null, initial: EMPTY_FORM, label: t('project_new'), enabled: creating });
const form = draft.values;
const setForm = draft.setValues;
const guardedClose = useGuardedClose();
const pendingNew = hasDraftFor(useUserDrafts(), 'project-new', null);
useEffect(() => { if (pendingNew) setCreating(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// Busca tambien en actividades y tareas de CUALQUIER proyecto (no solo el nombre del proyecto,
// que ya filtra la tabla mas abajo via getSearchHaystack) — resultados con su ruta completa
// "Proyecto - Actividad - Tarea" en un panel flotante bajo el buscador.
const debouncedSearch = useDebouncedValue(search, 300);
const searchQuery = debouncedSearch.trim();
const { data: searchResults } = useAsync(
() => (searchQuery.length >= 2 ? searchApi(searchQuery) : Promise.resolve(null)),
@@ -125,8 +141,8 @@ export default function ProyectosPage() {
startDate: form.startDate || null,
estimatedEndDate: form.estimatedEndDate || null,
});
draft.clear();
setCreating(false);
setForm(EMPTY_FORM);
pushToast({ tone: 'success', message: t('common_saved') });
navigate(`/proyectos/${project.id}`);
});
@@ -193,16 +209,7 @@ export default function ProyectosPage() {
if (!projects) return <div className="text-muted">{t('common_loading')}</div>;
const dueSoonProjects = filterDueSoon && !showTrash
? projects.filter((p) => {
if (p.status_is_closed || !p.estimated_end_date) return false;
const diffDays = Math.round((new Date(p.estimated_end_date) - new Date()) / 86400000);
return diffDays >= 0 && diffDays <= 7;
})
: projects;
const visibleProjects = showTrash || !progressFilter
? dueSoonProjects
: dueSoonProjects.filter((p) => (Number(p.progress_pct) >= 100) === (progressFilter === 'only'));
const visibleProjects = projects;
return (
<div>
@@ -217,7 +224,7 @@ export default function ProyectosPage() {
{isSuperadmin && (
<Button variant="standard" icon={Trash2} onClick={() => setShowTrash(true)}>{t('project_trash')}</Button>
)}
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}</Button>
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}{pendingNew && !creating && <DraftMark />}</Button>
</div>
)}
</div>
@@ -284,7 +291,7 @@ export default function ProyectosPage() {
<ListView
columns={columns}
rows={visibleProjects}
searchValue={search}
searchValue=""
getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`}
getRowKey={(p) => p.id}
onRowClick={showTrash ? undefined : (p) => navigate(`/proyectos/${p.id}`)}
@@ -301,52 +308,60 @@ export default function ProyectosPage() {
}
/>
<DetailPopup open={creating} title={t('project_new')} onClose={() => setCreating(false)} width={720} height={720}>
<Field label={t('common_name')}>
<div className="toolbar" style={{ justifyContent: 'flex-end', marginTop: 8 }}>
<span className="text-muted">{t('project_page_range')} {projectPage.total ? (page - 1) * pageSize + 1 : 0}–{Math.min(page * pageSize, projectPage.total)} {t('project_page_of')} {projectPage.total}</span>
<ComboBox options={[10, 20, 50, 100].map((n) => ({ value: n, label: String(n) }))} value={pageSize} onChange={setPageSize} />
<Button variant="standard" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>{t('project_page_previous')}</Button>
<Button variant="standard" disabled={page * pageSize >= projectPage.total} onClick={() => setPage((p) => p + 1)}>{t('project_page_next')}</Button>
</div>
<DetailPopup open={creating} title={t('project_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={720} height={720}>
<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>
<Field label={t('project_objective')}>
<Field label={t('project_objective')} dirty={draft.isFieldDirty('objective')}>
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('project_type')}>
<Field label={t('project_type')} dirty={draft.isFieldDirty('projectTypeId')}>
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: 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('project_requesting_department')}>
<Field label={t('project_requesting_department')} dirty={draft.isFieldDirty('requestingDepartmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
</Field>
<Field label={t('project_responsible_department')}>
<Field label={t('project_responsible_department')} dirty={draft.isFieldDirty('responsibleDepartmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
</Field>
<Field label={t('project_risk')}>
<Field label={t('project_risk')} dirty={draft.isFieldDirty('riskLevel')}>
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_start_date')}>
<Field label={t('project_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('project_estimated_end_date')}>
<Field label={t('project_estimated_end_date')} dirty={draft.isFieldDirty('estimatedEndDate')}>
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
</Field>
</FieldRow>
<Field label={t('project_notes')}>
<Field label={t('project_notes')} dirty={draft.isFieldDirty('notes')}>
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
</Field>
<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>
</div>