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:
@@ -30,7 +30,7 @@ function toForm(a) {
|
||||
};
|
||||
}
|
||||
|
||||
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged }) {
|
||||
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, initialTab }) {
|
||||
const { t, pushToast } = useUi();
|
||||
const runAction = useAction();
|
||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||
@@ -42,7 +42,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
await reloadTasks();
|
||||
});
|
||||
|
||||
const [tab, setTab] = useState('detail');
|
||||
const [tab, setTab] = useState(initialTab || 'detail');
|
||||
// Recargadas al cambiar de pestaña (listas livianas) para que los contadores de las pestañas
|
||||
// se mantengan al dia sin necesidad de que CommentsPanel/AttachmentsPanel avisen al padre.
|
||||
const { data: comments } = useAsync(() => listComments('activity', activity.id), [activity.id, tab]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync } from '../../hooks/useAsync.js';
|
||||
@@ -20,7 +20,12 @@ export default function ProyectoDetallePage() {
|
||||
const projectId = Number(id);
|
||||
const { t } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState('info');
|
||||
const [searchParams] = useSearchParams();
|
||||
// Llegada desde el buscador (Proyectos): ?tab=activities&activity=<id>&activityTab=tasks abre
|
||||
// directo la actividad (y su pestaña de tareas) que coincidio con la busqueda.
|
||||
const [tab, setTab] = useState(searchParams.get('tab') || 'info');
|
||||
const deepLinkActivityId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
|
||||
const deepLinkActivityTab = searchParams.get('activityTab') || undefined;
|
||||
|
||||
const { data: project, reload } = useAsync(() => getProject(projectId), [projectId]);
|
||||
|
||||
@@ -52,7 +57,9 @@ export default function ProyectoDetallePage() {
|
||||
|
||||
{tab === 'info' && <ProjectInfoTab project={project} onChanged={reload} />}
|
||||
{tab === 'members' && <ProjectMembersTab project={project} onChanged={reload} />}
|
||||
{tab === 'activities' && <ProjectActivitiesTab projectId={projectId} />}
|
||||
{tab === 'activities' && (
|
||||
<ProjectActivitiesTab projectId={projectId} openActivityId={deepLinkActivityId} openActivityTab={deepLinkActivityTab} />
|
||||
)}
|
||||
{tab === 'gantt' && <ProjectGanttTab projectId={projectId} />}
|
||||
{tab === 'calendar' && <ProjectCalendarTab projectId={projectId} />}
|
||||
{tab === 'comments' && <CommentsPanel entityType="project" entityId={projectId} />}
|
||||
|
||||
@@ -4,11 +4,14 @@ 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 { 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';
|
||||
@@ -57,6 +60,33 @@ export default function ProyectosPage() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
|
||||
// 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)),
|
||||
[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 }));
|
||||
@@ -169,7 +199,12 @@ export default function ProyectosPage() {
|
||||
{showTrash && <div className="text-muted" style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('project_trash_empty_hint')}</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>
|
||||
{!showTrash && (
|
||||
<>
|
||||
<div style={{ width: 160 }}>
|
||||
|
||||
Reference in New Issue
Block a user