diff --git a/API.md b/API.md
index 5b54dd5..a9dd1f4 100644
--- a/API.md
+++ b/API.md
@@ -111,6 +111,14 @@
### Notificaciones (`/api/notifications`)
- `GET /api/notifications` · `PATCH /api/notifications/:id/read` · `PATCH /api/notifications/read-all`
+### Búsqueda (`/api/search`)
+- `GET /api/search?q=texto` — global: proyectos, actividades y tareas (de cualquier proyecto) que
+ coincidan. `{ projects: [...], activities: [...], tasks: [...] }`, cada item de actividades/tareas
+ incluye los datos de su proyecto (y actividad, para tareas) para armar la migaja de pan en el
+ cliente. `q` con menos de 2 caracteres devuelve listas vacías. Máx 20 resultados por tipo.
+- `GET /api/search?q=texto&projectId=1` — acotado a un proyecto: solo `{ activities, tasks }` de
+ ESE proyecto (sin el dato del proyecto, ya es el contexto).
+
### Dashboard (`/api/dashboard`)
- `GET /api/dashboard/summary` — KPIs (activos, atrasados, pendientes/vencidas/hoy, avance promedio)
- `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf25894..c174391 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog - project.linkedcorp.net
+## [1.18.0] - 2026-09-16
+### Added
+- Buscador de Proyectos ahora también busca en actividades y tareas de cualquier proyecto, no
+ solo el nombre/código del proyecto. Los resultados aparecen en un panel bajo el buscador con la
+ ruta completa ("Proyecto - Actividad - Tarea"); al hacer clic navega directo al proyecto y abre
+ la actividad encontrada (la pestaña "Tareas" si el resultado era una tarea).
+- Buscador de Actividades (dentro de un proyecto) también busca en las tareas de esa actividad,
+ mismo panel de resultados con "Actividad - Tarea".
+- Nuevo endpoint `GET /api/search?q=...&projectId=` (global o acotado a un proyecto).
+
## [1.17.0] - 2026-09-16
### Added
- Mover una actividad a otro proyecto (menú de tres puntos en el listado de Actividades): se
diff --git a/VERSION b/VERSION
index 092afa1..84cc529 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.17.0
+1.18.0
diff --git a/backend/package.json b/backend/package.json
index 29d30cd..3443bd1 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
- "version": "1.17.0",
+ "version": "1.18.0",
"private": true,
"type": "module",
"main": "src/server.js",
diff --git a/backend/src/app.js b/backend/src/app.js
index e8c2b4d..c24e084 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -19,6 +19,7 @@ import auditRoutes from './modules/audit/routes.js';
import settingsRoutes from './modules/settings/routes.js';
import companyRoutes from './modules/company/routes.js';
import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
+import searchRoutes from './modules/search/routes.js';
export const app = express();
@@ -42,6 +43,7 @@ app.use('/api/audit', auditRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/company', companyRoutes);
app.use('/api/recurring-tasks', recurringTasksRoutes);
+app.use('/api/search', searchRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
diff --git a/backend/src/modules/search/controller.js b/backend/src/modules/search/controller.js
new file mode 100644
index 0000000..6705cc0
--- /dev/null
+++ b/backend/src/modules/search/controller.js
@@ -0,0 +1,6 @@
+import { ok, asyncHandler } from '../../utils/http.js';
+import * as service from './service.js';
+
+export const getSearch = asyncHandler(async (req, res) => {
+ ok(res, await service.search(req.query.q, req.query.projectId));
+});
diff --git a/backend/src/modules/search/repository.js b/backend/src/modules/search/repository.js
new file mode 100644
index 0000000..db96a96
--- /dev/null
+++ b/backend/src/modules/search/repository.js
@@ -0,0 +1,64 @@
+import { pool } from '../../config/db.js';
+
+const RESULT_LIMIT = 20;
+
+// Busqueda global (menu Proyectos): proyectos, actividades (con su proyecto) y tareas (con su
+// actividad y proyecto) — para armar la migaja de pan "Proyecto - Actividad - Tarea" en el cliente.
+export async function searchGlobal(term) {
+ const like = `%${term}%`;
+
+ const [projects] = await pool.query(
+ `SELECT id, code, name FROM projects
+ WHERE deleted_at IS NULL AND (code LIKE ? OR name LIKE ?)
+ ORDER BY name LIMIT ?`,
+ [like, like, RESULT_LIMIT]
+ );
+
+ const [activities] = await pool.query(
+ `SELECT a.id, a.code, a.name,
+ p.id AS project_id, p.code AS project_code, p.name AS project_name
+ FROM activities a
+ JOIN projects p ON p.id = a.project_id
+ WHERE p.deleted_at IS NULL AND (a.code LIKE ? OR a.name LIKE ?)
+ ORDER BY a.name LIMIT ?`,
+ [like, like, RESULT_LIMIT]
+ );
+
+ const [tasks] = await pool.query(
+ `SELECT t.id, t.name,
+ a.id AS activity_id, a.code AS activity_code, a.name AS activity_name,
+ p.id AS project_id, p.code AS project_code, p.name AS project_name
+ FROM tasks t
+ JOIN activities a ON a.id = t.activity_id
+ JOIN projects p ON p.id = a.project_id
+ WHERE p.deleted_at IS NULL AND t.name LIKE ?
+ ORDER BY t.name LIMIT ?`,
+ [like, RESULT_LIMIT]
+ );
+
+ return { projects, activities, tasks };
+}
+
+// Busqueda dentro de un proyecto (pestaña Actividades): solo actividades y tareas de ESE
+// proyecto — la migaja de pan ahi es "Actividad - Tarea" (el proyecto ya es el contexto visible).
+export async function searchInProject(projectId, term) {
+ const like = `%${term}%`;
+
+ const [activities] = await pool.query(
+ `SELECT id, code, name FROM activities
+ WHERE project_id = ? AND (code LIKE ? OR name LIKE ?)
+ ORDER BY name LIMIT ?`,
+ [projectId, like, like, RESULT_LIMIT]
+ );
+
+ const [tasks] = await pool.query(
+ `SELECT t.id, t.name, a.id AS activity_id, a.code AS activity_code, a.name AS activity_name
+ FROM tasks t
+ JOIN activities a ON a.id = t.activity_id
+ WHERE a.project_id = ? AND t.name LIKE ?
+ ORDER BY t.name LIMIT ?`,
+ [projectId, like, RESULT_LIMIT]
+ );
+
+ return { activities, tasks };
+}
diff --git a/backend/src/modules/search/routes.js b/backend/src/modules/search/routes.js
new file mode 100644
index 0000000..63dbac9
--- /dev/null
+++ b/backend/src/modules/search/routes.js
@@ -0,0 +1,10 @@
+import { Router } from 'express';
+import { requireAuth } from '../../middleware/auth.js';
+import { getSearch } from './controller.js';
+
+const router = Router();
+
+router.use(requireAuth);
+router.get('/', getSearch);
+
+export default router;
diff --git a/backend/src/modules/search/service.js b/backend/src/modules/search/service.js
new file mode 100644
index 0000000..6a6bf48
--- /dev/null
+++ b/backend/src/modules/search/service.js
@@ -0,0 +1,11 @@
+import * as repo from './repository.js';
+
+const MIN_LENGTH = 2;
+
+export async function search(term, projectId) {
+ const q = (term || '').trim();
+ if (q.length < MIN_LENGTH) {
+ return projectId ? { activities: [], tasks: [] } : { projects: [], activities: [], tasks: [] };
+ }
+ return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q);
+}
diff --git a/frontend/package.json b/frontend/package.json
index 64326ce..c42377b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
- "version": "1.17.0",
+ "version": "1.18.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/SearchResultsDropdown.jsx b/frontend/src/components/SearchResultsDropdown.jsx
new file mode 100644
index 0000000..c362a9e
--- /dev/null
+++ b/frontend/src/components/SearchResultsDropdown.jsx
@@ -0,0 +1,24 @@
+// Generico: el llamador arma `items` como [{ key, breadcrumb: [..partes..], onClick }] — este
+// componente solo los pinta como "parte1 - parte2 - parte3", clicables, en un panel flotante bajo
+// el buscador. Reutilizado por el buscador global de Proyectos ("Proyecto - Actividad - Tarea") y
+// por el buscador de Actividades dentro de un proyecto ("Actividad - Tarea").
+export function SearchResultsDropdown({ items, emptyLabel }) {
+ return (
+
+ {items.length === 0 ? (
+
{emptyLabel}
+ ) : (
+ items.map((item) => (
+
+ ))
+ )}
+
+ );
+}
diff --git a/frontend/src/hooks/useDebouncedValue.js b/frontend/src/hooks/useDebouncedValue.js
new file mode 100644
index 0000000..d02afc5
--- /dev/null
+++ b/frontend/src/hooks/useDebouncedValue.js
@@ -0,0 +1,12 @@
+import { useEffect, useState } from 'react';
+
+// Generico: devuelve `value` recien despues de `delayMs` sin cambios. Reutilizado por cualquier
+// campo que dispare una llamada a la API mientras el usuario escribe (ej. buscadores).
+export function useDebouncedValue(value, delayMs = 300) {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const id = setTimeout(() => setDebounced(value), delayMs);
+ return () => clearTimeout(id);
+ }, [value, delayMs]);
+ return debounced;
+}
diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js
index 8dffbe6..83f803b 100644
--- a/frontend/src/i18n/messages.js
+++ b/frontend/src/i18n/messages.js
@@ -80,6 +80,7 @@ export const messages = {
common_delete: 'Eliminar',
common_close: 'Cerrar',
common_search: 'Buscar...',
+ common_search_no_results: 'Sin resultados',
common_actions: 'Acciones',
common_view: 'Ver',
common_yes: 'Si',
@@ -327,6 +328,7 @@ export const messages = {
common_delete: 'Delete',
common_close: 'Close',
common_search: 'Search...',
+ common_search_no_results: 'No results',
common_actions: 'Actions',
common_view: 'View',
common_yes: 'Yes',
diff --git a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx
index db89cc2..1b6bc8d 100644
--- a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx
+++ b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx
@@ -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]);
diff --git a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx
index 3487273..852b90c 100644
--- a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx
+++ b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx
@@ -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 (