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 (
- +
+ + {searchQuery.length >= 2 && searchItems && ( + + )} +
@@ -214,6 +254,7 @@ export default function ProjectActivitiesTab({ projectId }) { projectId={projectId} siblingActivities={activities} onChanged={refreshAll} + initialTab={selectedInitialTab} /> )} diff --git a/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx b/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx index 8ef3260..cb13066 100644 --- a/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx +++ b/frontend/src/pages/Proyectos/ProyectoDetallePage.jsx @@ -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=&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' && } {tab === 'members' && } - {tab === 'activities' && } + {tab === 'activities' && ( + + )} {tab === 'gantt' && } {tab === 'calendar' && } {tab === 'comments' && } diff --git a/frontend/src/pages/Proyectos/ProyectosPage.jsx b/frontend/src/pages/Proyectos/ProyectosPage.jsx index 2875f98..43457e4 100644 --- a/frontend/src/pages/Proyectos/ProyectosPage.jsx +++ b/frontend/src/pages/Proyectos/ProyectosPage.jsx @@ -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 &&
{t('project_trash_empty_hint')}
}
- +
+ + {searchQuery.length >= 2 && searchItems && ( + + )} +
{!showTrash && ( <>
diff --git a/frontend/src/services/search.js b/frontend/src/services/search.js new file mode 100644 index 0000000..c03d90d --- /dev/null +++ b/frontend/src/services/search.js @@ -0,0 +1,7 @@ +import { api } from './api.js'; + +export function search(q, projectId) { + const params = new URLSearchParams({ q }); + if (projectId) params.set('projectId', projectId); + return api.get(`/search?${params.toString()}`); +} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 0936d2f..399cdd9 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -261,6 +261,21 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; } .flyout-item.danger:hover { background: rgb(var(--danger) / .14); } .flyout-separator { height: 1px; background: rgb(var(--line)); margin: 4px 2px; } +/* --- Resultados de busqueda (Proyectos: global; Actividades: por proyecto) --- */ +.search-results-dropdown { + position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 80; + background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); + border-radius: var(--r-lg); box-shadow: 0 10px 28px rgb(0 0 0 / .35); + padding: 6px; max-height: 320px; overflow-y: auto; +} +.search-results-item { + display: block; width: 100%; text-align: left; padding: 8px 10px; border-radius: var(--r-sm); + background: none; border: none; cursor: pointer; font-size: .8rem; color: rgb(var(--body)); +} +.search-results-item:hover { background: rgb(var(--accent) / .14); } +.search-results-sep { color: rgb(var(--muted)); } +.search-results-empty { padding: 10px; font-size: .78rem; color: rgb(var(--muted)); } + /* --- Treeview --- */ .tree-item-row { display: flex; align-items: center; gap: 6px; padding: 6px 6px; border-radius: var(--r-sm); cursor: pointer; } .tree-item-row:hover { background: rgb(var(--panel)); }