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:
@@ -111,6 +111,14 @@
|
|||||||
### Notificaciones (`/api/notifications`)
|
### Notificaciones (`/api/notifications`)
|
||||||
- `GET /api/notifications` · `PATCH /api/notifications/:id/read` · `PATCH /api/notifications/read-all`
|
- `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`)
|
### Dashboard (`/api/dashboard`)
|
||||||
- `GET /api/dashboard/summary` — KPIs (activos, atrasados, pendientes/vencidas/hoy, avance promedio)
|
- `GET /api/dashboard/summary` — KPIs (activos, atrasados, pendientes/vencidas/hoy, avance promedio)
|
||||||
- `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite
|
- `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [1.17.0] - 2026-09-16
|
||||||
### Added
|
### Added
|
||||||
- Mover una actividad a otro proyecto (menú de tres puntos en el listado de Actividades): se
|
- Mover una actividad a otro proyecto (menú de tres puntos en el listado de Actividades): se
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "1.17.0",
|
"version": "1.18.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import auditRoutes from './modules/audit/routes.js';
|
|||||||
import settingsRoutes from './modules/settings/routes.js';
|
import settingsRoutes from './modules/settings/routes.js';
|
||||||
import companyRoutes from './modules/company/routes.js';
|
import companyRoutes from './modules/company/routes.js';
|
||||||
import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
|
import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
|
||||||
|
import searchRoutes from './modules/search/routes.js';
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ app.use('/api/audit', auditRoutes);
|
|||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/company', companyRoutes);
|
app.use('/api/company', companyRoutes);
|
||||||
app.use('/api/recurring-tasks', recurringTasksRoutes);
|
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
|
// 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
|
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||||
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.17.0",
|
"version": "1.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="search-results-dropdown">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="search-results-empty">{emptyLabel}</div>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<button key={item.key} type="button" className="search-results-item" onClick={item.onClick}>
|
||||||
|
{item.breadcrumb.map((part, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && <span className="search-results-sep"> — </span>}
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -80,6 +80,7 @@ export const messages = {
|
|||||||
common_delete: 'Eliminar',
|
common_delete: 'Eliminar',
|
||||||
common_close: 'Cerrar',
|
common_close: 'Cerrar',
|
||||||
common_search: 'Buscar...',
|
common_search: 'Buscar...',
|
||||||
|
common_search_no_results: 'Sin resultados',
|
||||||
common_actions: 'Acciones',
|
common_actions: 'Acciones',
|
||||||
common_view: 'Ver',
|
common_view: 'Ver',
|
||||||
common_yes: 'Si',
|
common_yes: 'Si',
|
||||||
@@ -327,6 +328,7 @@ export const messages = {
|
|||||||
common_delete: 'Delete',
|
common_delete: 'Delete',
|
||||||
common_close: 'Close',
|
common_close: 'Close',
|
||||||
common_search: 'Search...',
|
common_search: 'Search...',
|
||||||
|
common_search_no_results: 'No results',
|
||||||
common_actions: 'Actions',
|
common_actions: 'Actions',
|
||||||
common_view: 'View',
|
common_view: 'View',
|
||||||
common_yes: 'Yes',
|
common_yes: 'Yes',
|
||||||
|
|||||||
@@ -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 { t, pushToast } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||||
@@ -42,7 +42,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
await reloadTasks();
|
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
|
// 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.
|
// se mantengan al dia sin necesidad de que CommentsPanel/AttachmentsPanel avisen al padre.
|
||||||
const { data: comments } = useAsync(() => listComments('activity', activity.id), [activity.id, tab]);
|
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 { Plus, Save, Trash2, ArrowRightLeft } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
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 { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity } from '../../services/activities.js';
|
||||||
import { listProjects } from '../../services/projects.js';
|
import { listProjects } from '../../services/projects.js';
|
||||||
|
import { search as searchApi } from '../../services/search.js';
|
||||||
import { listCatalog } from '../../services/catalogs.js';
|
import { listCatalog } from '../../services/catalogs.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
import { ListView } from '../../components/ListView.jsx';
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
|
import { SearchResultsDropdown } from '../../components/SearchResultsDropdown.jsx';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||||
import { ComboBox } from '../../components/ComboBox.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 };
|
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 { t, pushToast, openDialog } = useUi();
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
@@ -33,9 +36,40 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [selectedInitialTab, setSelectedInitialTab] = useState(undefined);
|
||||||
const [moving, setMoving] = useState(null);
|
const [moving, setMoving] = useState(null);
|
||||||
const [targetProjectId, setTargetProjectId] = useState('');
|
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 priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||||
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
||||||
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.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);
|
const full = await getActivity(row.id);
|
||||||
setSelected(full);
|
setSelected(full);
|
||||||
|
setSelectedInitialTab(initialTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
@@ -137,7 +172,12 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
<SearchInput value={search} onChange={setSearch} />
|
<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>
|
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -214,6 +254,7 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
siblingActivities={activities}
|
siblingActivities={activities}
|
||||||
onChanged={refreshAll}
|
onChanged={refreshAll}
|
||||||
|
initialTab={selectedInitialTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { ArrowLeft } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAsync } from '../../hooks/useAsync.js';
|
import { useAsync } from '../../hooks/useAsync.js';
|
||||||
@@ -20,7 +20,12 @@ export default function ProyectoDetallePage() {
|
|||||||
const projectId = Number(id);
|
const projectId = Number(id);
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const navigate = useNavigate();
|
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]);
|
const { data: project, reload } = useAsync(() => getProject(projectId), [projectId]);
|
||||||
|
|
||||||
@@ -52,7 +57,9 @@ export default function ProyectoDetallePage() {
|
|||||||
|
|
||||||
{tab === 'info' && <ProjectInfoTab project={project} onChanged={reload} />}
|
{tab === 'info' && <ProjectInfoTab project={project} onChanged={reload} />}
|
||||||
{tab === 'members' && <ProjectMembersTab 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 === 'gantt' && <ProjectGanttTab projectId={projectId} />}
|
||||||
{tab === 'calendar' && <ProjectCalendarTab projectId={projectId} />}
|
{tab === 'calendar' && <ProjectCalendarTab projectId={projectId} />}
|
||||||
{tab === 'comments' && <CommentsPanel entityType="project" entityId={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 { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
|
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
|
||||||
import { listProjects, createProject, deleteProject, restoreProject, permanentlyDeleteProject } from '../../services/projects.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 { listCatalog } from '../../services/catalogs.js';
|
||||||
import { listDepartments } from '../../services/departments.js';
|
import { listDepartments } from '../../services/departments.js';
|
||||||
import { ListView } from '../../components/ListView.jsx';
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
|
import { SearchResultsDropdown } from '../../components/SearchResultsDropdown.jsx';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
@@ -57,6 +60,33 @@ export default function ProyectosPage() {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
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 deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||||
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||||
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.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>}
|
{showTrash && <div className="text-muted" style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('project_trash_empty_hint')}</div>}
|
||||||
|
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
<SearchInput value={search} onChange={setSearch} />
|
<SearchInput value={search} onChange={setSearch} />
|
||||||
|
{searchQuery.length >= 2 && searchItems && (
|
||||||
|
<SearchResultsDropdown items={searchItems} emptyLabel={t('common_search_no_results')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!showTrash && (
|
{!showTrash && (
|
||||||
<>
|
<>
|
||||||
<div style={{ width: 160 }}>
|
<div style={{ width: 160 }}>
|
||||||
|
|||||||
@@ -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()}`);
|
||||||
|
}
|
||||||
@@ -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-item.danger:hover { background: rgb(var(--danger) / .14); }
|
||||||
.flyout-separator { height: 1px; background: rgb(var(--line)); margin: 4px 2px; }
|
.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 --- */
|
/* --- Treeview --- */
|
||||||
.tree-item-row { display: flex; align-items: center; gap: 6px; padding: 6px 6px; border-radius: var(--r-sm); cursor: pointer; }
|
.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)); }
|
.tree-item-row:hover { background: rgb(var(--panel)); }
|
||||||
|
|||||||
Reference in New Issue
Block a user