e65111a7f4
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>
370 lines
20 KiB
React
370 lines
20 KiB
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';
|
||
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>
|
||
);
|
||
}
|