Files
project.linkedcorp.net/frontend/src/pages/Proyectos/ProyectosPage.jsx
T
Milton Gonzalez e65111a7f4 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>
2026-10-23 11:36:02 -05:00

370 lines
20 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
import { listProjects, createProject, deleteProject, restoreProject, permanentlyDeleteProject } from '../../services/projects.js';
import { listUsers } from '../../services/users.js';
import { search as searchApi } from '../../services/search.js';
import { listCatalog } from '../../services/catalogs.js';
import { listDepartments } from '../../services/departments.js';
import { ListView } from '../../components/ListView.jsx';
import { SearchInput } from '../../components/SearchInput.jsx';
import { SearchResultsDropdown } from '../../components/SearchResultsDropdown.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
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'];
const PROGRESS_FILTER_KEY = 'plc_projects_progress_filter';
const LEGACY_HIDE_COMPLETED_KEY = 'plc_projects_hide_completed';
function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
return acc;
}
const RISK_COLOR = { low: 'success', medium: 'warning', high: 'danger' };
const EMPTY_FORM = {
name: '', description: '', objective: '', projectTypeId: '', priorityId: '', statusId: '',
requestingDepartmentId: '', responsibleDepartmentId: '', startDate: '', estimatedEndDate: '',
riskLevel: 'low', notes: '',
};
export default function ProyectosPage() {
const { t, pushToast, openDialog } = useUi();
const { user: currentUser } = useAuth();
const navigate = useNavigate();
const runAction = useAction();
const [showTrash, setShowTrash] = useState(false);
const isSuperadmin = currentUser?.roleCode === 'superadmin';
const canDelete = CAN_DELETE_ROLES.includes(currentUser?.roleCode);
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(() => {
try {
const saved = localStorage.getItem(PROGRESS_FILTER_KEY);
if (saved !== null) return saved;
return localStorage.getItem(LEGACY_HIDE_COMPLETED_KEY) === '1' ? 'hide' : '';
} catch { return ''; }
});
function changeProgressFilter(value) {
setProgressFilter(value);
try { localStorage.setItem(PROGRESS_FILTER_KEY, value); } catch { /* sin storage: solo en memoria */ }
}
const [filterOwnerUserId, setFilterOwnerUserId] = useState('');
// 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: 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, []);
useEffect(() => { setPage(1); }, [showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, search, pageSize]);
const [creating, setCreating] = useState(false);
// "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 searchQuery = debouncedSearch.trim();
const { data: searchResults } = useAsync(
() => (searchQuery.length >= 2 ? searchApi(searchQuery) : Promise.resolve(null)),
[searchQuery]
);
const searchItems = searchResults ? [
...searchResults.projects.map((p) => ({
key: `p-${p.id}`,
breadcrumb: [`${p.code} — ${p.name}`],
onClick: () => { setSearch(''); navigate(`/proyectos/${p.id}`); },
})),
...searchResults.activities.map((a) => ({
key: `a-${a.id}`,
breadcrumb: [`${a.project_code} — ${a.project_name}`, `${a.code} — ${a.name}`],
onClick: () => { setSearch(''); navigate(`/proyectos/${a.project_id}?tab=activities&activity=${a.id}`); },
})),
...searchResults.tasks.map((tk) => ({
key: `t-${tk.id}`,
breadcrumb: [`${tk.project_code} — ${tk.project_name}`, `${tk.activity_code} — ${tk.activity_name}`, tk.name],
onClick: () => { setSearch(''); navigate(`/proyectos/${tk.project_id}?tab=activities&activity=${tk.activity_id}&activityTab=tasks`); },
})),
] : null;
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
const typeOptions = (types || []).map((tp) => ({ value: tp.id, label: tp.name }));
async function save() {
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
await runAction(async () => {
const project = await createProject({
...form,
projectTypeId: form.projectTypeId || null,
requestingDepartmentId: form.requestingDepartmentId || null,
responsibleDepartmentId: form.responsibleDepartmentId || null,
startDate: form.startDate || null,
estimatedEndDate: form.estimatedEndDate || null,
});
draft.clear();
setCreating(false);
pushToast({ tone: 'success', message: t('common_saved') });
navigate(`/proyectos/${project.id}`);
});
}
function confirmDelete(project) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('project_delete_confirm_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteProject(project.id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
function confirmPurge(project) {
openDialog({
tone: 'error',
title: t('project_delete_permanent'),
message: t('project_delete_permanent_confirm_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('project_delete_permanent'), variant: 'danger',
onClick: () => runAction(async () => {
await permanentlyDeleteProject(project.id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
function restore(project) {
runAction(async () => {
await restoreProject(project.id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
});
}
const columns = [
{ key: 'code', label: t('project_code') },
{ key: 'name', label: t('common_name') },
{ key: 'status_name', label: t('common_status'), render: (r) => <StatusBadge name={r.status_name} color={r.status_color} /> },
{ key: 'priority_name', label: t('common_priority'), sortValue: (r) => r.priority_level, render: (r) => <StatusBadge name={r.priority_name} color={r.priority_color} /> },
{ key: 'owner_name', label: t('project_owner') },
{ key: 'progress_pct', label: t('project_progress'), render: (r) => <div style={{ width: 90 }}><ProgressBar value={Number(r.progress_pct)} /></div> },
{ key: 'shared', label: '', sortable: false, render: (r) => <SharedWithIcon projectId={r.id} count={r.shared_count} /> },
{ key: 'risk_level', label: t('project_risk'), render: (r) => <Badge color={RISK_COLOR[r.risk_level]}>{t(`risk_${r.risk_level}`)}</Badge> },
{ key: 'updated_at', label: t('project_updated_at'), render: (r) => String(r.updated_at).slice(0, 16) },
...(showTrash ? [{ key: 'deleted_at', label: t('project_deleted_at'), render: (r) => String(r.deleted_at).slice(0, 10) }] : []),
];
if (!projects) return <div className="text-muted">{t('common_loading')}</div>;
const visibleProjects = projects;
return (
<div>
<div className="toolbar">
<div className="toolbar-left">
<h2 className="page-title mt-0">{showTrash ? t('project_trash_title') : t('projects_title')}</h2>
</div>
{showTrash ? (
<Button variant="standard" icon={ArrowLeft} onClick={() => setShowTrash(false)}>{t('project_trash_back')}</Button>
) : (
<div style={{ display: 'flex', gap: 8 }}>
{isSuperadmin && (
<Button variant="standard" icon={Trash2} onClick={() => setShowTrash(true)}>{t('project_trash')}</Button>
)}
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}{pendingNew && !creating && <DraftMark />}</Button>
</div>
)}
</div>
{showTrash && <div className="text-muted" style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('project_trash_empty_hint')}</div>}
<div className="toolbar">
<div style={{ position: 'relative' }}>
<SearchInput value={search} onChange={setSearch} />
{searchQuery.length >= 2 && searchItems && (
<SearchResultsDropdown items={searchItems} emptyLabel={t('common_search_no_results')} />
)}
</div>
{!showTrash && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>{t('common_status')}</span>
<div style={{ width: 160 }}>
<ComboBox options={[{ value: '', label: t('common_all') }, ...statusOptions]} value={filterStatusId} onChange={setFilterStatusId} placeholder={t('common_status')} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>{t('common_priority')}</span>
<div style={{ width: 160 }}>
<ComboBox options={[{ value: '', label: t('common_all') }, ...priorityOptions]} value={filterPriorityId} onChange={setFilterPriorityId} placeholder={t('common_priority')} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>{t('project_filter_progress')}</span>
<div style={{ width: 160 }}>
<ComboBox
options={[
{ value: '', label: t('common_all') },
{ value: 'hide', label: t('project_filter_progress_hide') },
{ value: 'only', label: t('project_filter_progress_only') },
]}
value={progressFilter}
onChange={changeProgressFilter}
placeholder={t('project_filter_progress')}
/>
</div>
</div>
{canFilterByUser && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="text-muted" style={{ fontSize: '.72rem', whiteSpace: 'nowrap' }}>Usuario</span>
<div style={{ width: 200 }}>
<ComboBox
options={[{ value: '', label: t('common_all') }, ...(allUsers || []).map((u) => ({ value: u.id, label: u.name }))]}
value={filterOwnerUserId}
onChange={setFilterOwnerUserId}
placeholder="Usuario"
/>
</div>
</div>
)}
<label className="checkbox-row">
<input type="checkbox" checked={filterDueSoon} onChange={(e) => setFilterDueSoon(e.target.checked)} />
{t('project_filter_due_soon')}
</label>
</>
)}
</div>
<ListView
columns={columns}
rows={visibleProjects}
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}`)}
initialSort={{ key: 'updated_at', dir: -1 }}
rowActions={
showTrash
? (p) => [
{ label: t('project_restore'), icon: RotateCcw, onClick: () => restore(p) },
{ label: t('project_delete_permanent'), icon: Trash2, danger: true, onClick: () => confirmPurge(p) },
]
: canDelete
? (p) => [{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(p) }]
: undefined
}
/>
<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')} 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')} 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')} 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')} dirty={draft.isFieldDirty('priorityId')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<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')} 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')} 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')} 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')} 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')} 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')} 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={() => { draft.discard(); setCreating(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
);
}