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) => }, { key: 'priority_name', label: t('common_priority'), sortValue: (r) => r.priority_level, render: (r) => }, { key: 'owner_name', label: t('project_owner') }, { key: 'progress_pct', label: t('project_progress'), render: (r) =>
}, { key: 'shared', label: '', sortable: false, render: (r) => }, { key: 'risk_level', label: t('project_risk'), render: (r) => {t(`risk_${r.risk_level}`)} }, { 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
{t('common_loading')}
; const visibleProjects = projects; return (

{showTrash ? t('project_trash_title') : t('projects_title')}

{showTrash ? ( ) : (
{isSuperadmin && ( )}
)}
{showTrash &&
{t('project_trash_empty_hint')}
}
{searchQuery.length >= 2 && searchItems && ( )}
{!showTrash && ( <>
{t('common_status')}
{t('common_priority')}
{t('project_filter_progress')}
{canFilterByUser && (
Usuario
({ value: u.id, label: u.name }))]} value={filterOwnerUserId} onChange={setFilterOwnerUserId} placeholder="Usuario" />
)} )}
`${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 } />
{t('project_page_range')} {projectPage.total ? (page - 1) * pageSize + 1 : 0}–{Math.min(page * pageSize, projectPage.total)} {t('project_page_of')} {projectPage.total} ({ value: n, label: String(n) }))} value={pageSize} onChange={setPageSize} />
guardedClose(draft, () => setCreating(false))} width={720} height={720}> setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />