feat: el buscador de Proyectos y Actividades tambien busca en tareas

Buscador de Proyectos: ahora busca tambien en actividades y tareas de cualquier proyecto (antes
solo nombre/codigo del proyecto). Resultados en un panel flotante con la ruta completa
"Proyecto - Actividad - Tarea"; clic navega al proyecto y abre la actividad encontrada
(pestaña Tareas si el resultado era una tarea).

Buscador de Actividades (dentro de un proyecto): busca tambien en las tareas de esa actividad,
mismo panel con "Actividad - Tarea".

Nuevo GET /api/search?q=...&projectId= (global o acotado a un proyecto). Nuevos componentes/hooks
reutilizables: SearchResultsDropdown, useDebouncedValue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-16 15:04:53 -05:00
parent 528a1e7cae
commit 67cb58ed0b
19 changed files with 267 additions and 13 deletions
@@ -1,14 +1,17 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Plus, Save, Trash2, ArrowRightLeft } 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 } from '../../services/activities.js';
import { listProjects } from '../../services/projects.js';
import { search as searchApi } from '../../services/search.js';
import { listCatalog } from '../../services/catalogs.js';
import { listUsers } from '../../services/users.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';
@@ -19,7 +22,7 @@ import ActivityDetailPanel from './ActivityDetailPanel.jsx';
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
export default function ProjectActivitiesTab({ projectId }) {
export default function ProjectActivitiesTab({ projectId, openActivityId, openActivityTab }) {
const { t, pushToast, openDialog } = useUi();
const { user: currentUser } = useAuth();
const runAction = useAction();
@@ -33,9 +36,40 @@ export default function ProjectActivitiesTab({ projectId }) {
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('');
// 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.
const debouncedSearch = useDebouncedValue(search, 300);
const searchQuery = debouncedSearch.trim();
const { data: searchResults } = useAsync(
() => (searchQuery.length >= 2 ? searchApi(searchQuery, projectId) : Promise.resolve(null)),
[searchQuery, projectId]
);
const searchItems = searchResults ? [
...searchResults.activities.map((a) => ({
key: `a-${a.id}`,
breadcrumb: [`${a.code} — ${a.name}`],
onClick: () => { setSearch(''); openActivity({ id: a.id }); },
})),
...searchResults.tasks.map((tk) => ({
key: `t-${tk.id}`,
breadcrumb: [`${tk.activity_code} — ${tk.activity_name}`, tk.name],
onClick: () => { setSearch(''); openActivity({ id: tk.activity_id }, 'tasks'); },
})),
] : null;
// Llegada desde el buscador global de Proyectos: abre directo la actividad indicada (una sola vez).
const openedFromDeepLink = useRef(false);
useEffect(() => {
if (openedFromDeepLink.current || !openActivityId || !activities) return;
openedFromDeepLink.current = true;
openActivity({ id: openActivityId }, openActivityTab);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openActivityId, activities]);
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
@@ -54,9 +88,10 @@ export default function ProjectActivitiesTab({ projectId }) {
});
}
async function openActivity(row) {
async function openActivity(row, initialTab) {
const full = await getActivity(row.id);
setSelected(full);
setSelectedInitialTab(initialTab);
}
async function refreshAll() {
@@ -137,7 +172,12 @@ export default function ProjectActivitiesTab({ projectId }) {
return (
<div>
<div className="toolbar">
<SearchInput value={search} onChange={setSearch} />
<div style={{ position: 'relative' }}>
<SearchInput value={search} onChange={setSearch} />
{searchQuery.length >= 2 && searchItems && (
<SearchResultsDropdown items={searchItems} emptyLabel={t('common_search_no_results')} />
)}
</div>
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
</div>
@@ -214,6 +254,7 @@ export default function ProjectActivitiesTab({ projectId }) {
projectId={projectId}
siblingActivities={activities}
onChanged={refreshAll}
initialTab={selectedInitialTab}
/>
)}
</DetailPopup>