diff --git a/DESIGN.md b/DESIGN.md index 4390f4e..dc613db 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -162,9 +162,9 @@ Fuente normativa: [patrón de listados](docs/patron-de-listados-de-entidades.md) 4. Tabla semántica; valores ausentes «—»; acciones fijadas a la derecha. Acción principal mediante icono, tooltip y nombre; secundarias en menú de tres puntos, sin duplicar la principal. 5. Rango repetido y paginación; filtros/tamaño ajustan página válida. No confundir cero resultados filtrados con falta de registros. -[EntityTable](packages/rdx-ui/src/components/entity-list.tsx) compone bandas, estados, contenido y controles; [useEntityTable](packages/rdx-ui/src/hooks/use-entity-table.ts) gestiona TanStack Table, orden, visibilidad y paginación **locales**. Nombre comienza ascendente salvo criterio explícito; comparación por idioma, sin tildes/mayúsculas y con cifras numéricas. Esto no describe el comportamiento de búsqueda: V07 sigue pendiente. +[EntityTable](packages/rdx-ui/src/components/entity-list.tsx) compone bandas, estados, contenido y controles; [useEntityTable](packages/rdx-ui/src/hooks/use-entity-table.ts) gestiona TanStack Table y visibilidad; orden/página son locales por defecto y controlables en los pilotos de [L03.1](docs/contexto-listados-l03-1.md). Nombre comienza ascendente salvo criterio explícito; comparación por idioma, sin tildes/mayúsculas y con cifras numéricas. Esto no describe el comportamiento de búsqueda: V07 sigue pendiente. -Columnas de inventario persisten con clave propia en localStorage; otros consumidores deben habilitar una clave explícita. Identificación y acciones no se ocultan. [L02.2](docs/configuracion-columnas-l02-2.md) incorpora búsqueda de columnas por etiqueta/keywords, grupos semánticos opcionales, visibilidad efectiva y restauración inmediata de defaults, conservando preferencias y protecciones (V08). Los listados sencillos no necesitan grupos. La búsqueda y ciertos filtros de rutas se conservan según su contrato; la restauración completa de orden/página no está implementada de forma general, V05. Cancelar un modal debe mantener contexto del listado. +Columnas de inventario persisten con clave propia en localStorage; otros consumidores deben habilitar una clave explícita. Identificación y acciones no se ocultan. [L02.2](docs/configuracion-columnas-l02-2.md) incorpora búsqueda de columnas por etiqueta/keywords, grupos semánticos opcionales, visibilidad efectiva y restauración inmediata de defaults, conservando preferencias y protecciones (V08). Los listados sencillos no necesitan grupos. En Activos, Recursos y Personas, [L03.1](docs/contexto-listados-l03-1.md) conserva filtros, sort y página en URL, y scroll/foco de retorno en History. Los demás consumidores mantienen su contrato; V05 no se declara universalmente resuelto. Cancelar un modal debe mantener contexto del listado. Familias por plantilla usan `ScrollableTabsList`; `min-w-0` y scroll horizontal quedan dentro del listado. `onRowClick` solo selecciona desde zonas no interactivas, conservando enlaces, menús y controles internos. No añadir selección múltiple sin operación consumidora. @@ -269,7 +269,7 @@ V02, V03, V06 y V09 están **resueltos en los escenarios verificados del sprint | V02 | Resuelto: combinación de tokens/estados del inventario claro | Contraste y foco comprobados; no equivale a validar todos los temas o tablas | | V03 | Resuelto: adopción de patrón y pie estable | Tres pasos de activos/recursos verificados en los cuatro tamaños del sprint | | V04 | Diseño de ayuda/progreso compacto + componentes | Acordar presupuesto de altura sin perder ayuda junto al pie; verificar 320×640 | -| V05 | Estado/navegación de implementación | Definir restauración completa por vista; orden reproducido, página de activos pendiente por datos | +| V05 | Resuelto en los pilotos y escenarios de L03.1; pendiente en otros consumidores | URL validada, preferencias existentes y retorno de scroll/foco; véase [alcance](docs/contexto-listados-l03-1.md) | | V06 | Resuelto: integración de foco del inventario | Ratón/Enter, Escape/Cancelar, cabecera/fila, alternativa y selector anidado verificados | | V07 | Contrato de búsqueda y consumidores | Acordar normalización por idioma y probar tildes/ñ; no confundir con ordenación | | V08 | Resuelto en los escenarios verificados de L02.2 | Búsqueda, grupos opcionales, contador y restauración con preferencias compatibles | diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail-page.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail-page.tsx index 1a95d8a..0d1b4e3 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail-page.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail-page.tsx @@ -32,17 +32,29 @@ export function AssetDetailPage({ const c = useAssetDetailPageController(companyId, assetId, backHref); const { asset } = c; const template = c.catalog.data?.items.find((item) => item.id === asset?.values.templateId); + const backLink = ( + + + ); if (c.inventory.isSuccess && !asset) { - return ; + return ( +
+ {backLink} + +
+ ); } return (
- - + {backLink} {c.inventory.isPending &&

{t("loading")}

} {(c.inventory.isError || c.catalog.isError) && ( diff --git a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts index dd6b7fa..ba605fc 100644 --- a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts +++ b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts @@ -1,7 +1,7 @@ import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client"; import { useContextualDetail } from "@repo/rdx-ui/hooks/use-contextual-detail"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; import { @@ -41,8 +41,26 @@ export function useAssetInventoryPageController({ ? requestedTemplateId : null; + useEffect(() => { + if ( + catalog.isSuccess && + requestedTemplateId !== null && + (templateId === null || searchParams.getAll("template").length > 1) + ) { + setSearchParams((params) => { + params.delete("page"); + if (templateId) { + params.set("template", templateId); + } else { + params.delete("template"); + } + }); + } + }, [catalog.isSuccess, requestedTemplateId, templateId, searchParams, setSearchParams]); + const setTemplateId = (value: string) => setSearchParams((params) => { + params.delete("page"); if (value === "all") { params.delete("template"); } else { @@ -52,12 +70,14 @@ export function useAssetInventoryPageController({ const clearFilters = () => setSearchParams((params) => { + params.delete("page"); params.delete("q"); params.delete("template"); }); const setSearch = (value: string) => setSearchParams((params) => { + params.delete("page"); if (value) { params.set("q", value); } else { @@ -107,6 +127,7 @@ export function useAssetInventoryPageController({ const deleted = assets.find((asset) => asset.id === deleteId); return { + returnReady: !(query.isPending || query.isFetching || catalog.isPending || catalog.isFetching), assets, denied, query, diff --git a/modules/assets/src/web/assets/list/ui/inventory-page.tsx b/modules/assets/src/web/assets/list/ui/inventory-page.tsx index f349c63..1cee35d 100644 --- a/modules/assets/src/web/assets/list/ui/inventory-page.tsx +++ b/modules/assets/src/web/assets/list/ui/inventory-page.tsx @@ -1,4 +1,10 @@ -import { BrowserLink, EntityNotFound, navigateTo } from "@metis/core/client"; +import { + BrowserLink, + EntityNotFound, + navigateTo, + useListLocation, + useListReturn, +} from "@metis/core/client"; import { EntityAction, EntityListHeader, @@ -35,6 +41,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { const { companyId, lookups } = props; const { query, + returnReady, catalog, infrastructure, assets, @@ -68,6 +75,17 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { const catalogState = assetListQueryState(catalog, "catalog", t); const infrastructureState = assetListQueryState(infrastructure, "infrastructure", t); const canMutate = query.isSuccess && catalog.isSuccess; + const navigation = useListLocation( + { sort: "sort", direction: "direction", page: "page" }, + canMutate + ); + + useListReturn({ + scope: `/companies/${companyId}/assets`, + rootRef: inventoryRef, + ready: returnReady, + recordFocus: query.isSuccess, + }); useLayoutEffect(() => { if (missingView && document.activeElement === document.body) { @@ -235,6 +253,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { }, ]} infrastructure={infrastructure.data?.items ?? []} + navigation={navigation} onClearFilters={clearFilters} onRetry={denied ? undefined : () => query.refetch()} onRowClick={(asset) => selectAsset(asset.id)} diff --git a/modules/assets/src/web/assets/shared/ui/asset-list.tsx b/modules/assets/src/web/assets/shared/ui/asset-list.tsx index 2e0c989..50031bf 100644 --- a/modules/assets/src/web/assets/shared/ui/asset-list.tsx +++ b/modules/assets/src/web/assets/shared/ui/asset-list.tsx @@ -2,6 +2,7 @@ import type { EntityColumn } from "@repo/rdx-ui/components/entity-list"; import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list"; import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list"; import type { EntityListState } from "@repo/rdx-ui/lib/entity-list-state"; +import type { EntityTableNavigation } from "@repo/rdx-ui/lib/entity-table-navigation"; import { Skeleton } from "@repo/shadcn-ui/components/skeleton"; import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs"; import type { ReactNode } from "react"; @@ -38,6 +39,7 @@ export function AssetList({ loading = false, unavailable = false, state, + navigation, catalogState, onRetry, }: { @@ -60,6 +62,7 @@ export function AssetList({ loading?: boolean; unavailable?: boolean; state?: EntityListState | undefined; + navigation?: EntityTableNavigation | undefined; catalogState?: EntityListState; onRetry?: (() => Promise) | undefined; }) { @@ -235,6 +238,7 @@ export function AssetList({ layout={layout} loading={loading} locale={i18n.language} + navigation={navigation} onClearFilters={onClearFilters} onRetry={onRetry} onRowClick={onRowClick} diff --git a/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts b/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts index 0b33fc3..b0c0ce2 100644 --- a/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts +++ b/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts @@ -10,6 +10,7 @@ import { import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; +import type { PeopleListDataState } from "../../../people"; import { profileApi, profileKeys } from "../../../work-profiles/shared/api"; import { companyKeys } from "../../shared/queries"; import type { CompanyTab } from "../../shared/types"; @@ -54,5 +55,11 @@ export function useCompanyDetailController({ profiles: { count: profiles.data?.profiles.length, failed: profiles.isError }, }; - return { query, editOpen, setEditOpen, tab, setTab, company, counts }; + const peopleDataState: PeopleListDataState = query.isFetching + ? "pending" + : query.isError + ? "unverified" + : "ready"; + + return { peopleDataState, query, editOpen, setEditOpen, tab, setTab, company, counts }; } diff --git a/modules/companies/src/web/companies/detail/ui/company-detail.tsx b/modules/companies/src/web/companies/detail/ui/company-detail.tsx index 7524f04..028da66 100644 --- a/modules/companies/src/web/companies/detail/ui/company-detail.tsx +++ b/modules/companies/src/web/companies/detail/ui/company-detail.tsx @@ -25,7 +25,7 @@ export function CompanyDetail(props: CompanyDetailProps) { initialCreate, initialConfiguration, } = props; - const { query, editOpen, setEditOpen, tab, setTab, company, counts } = + const { query, editOpen, setEditOpen, tab, setTab, company, counts, peopleDataState } = useCompanyDetailController(props); const { t } = useTranslation("companies"); const navigation = useContext(WorkspaceMobileNavigationContext); @@ -128,6 +128,7 @@ export function CompanyDetail(props: CompanyDetailProps) { company={company} embedded={embedded} initialConfiguration={initialConfiguration} + peopleDataState={peopleDataState} {...initialValues} key={tab} tab={tab} diff --git a/modules/companies/src/web/companies/detail/ui/company-section.tsx b/modules/companies/src/web/companies/detail/ui/company-section.tsx index d9e9598..5d5eec3 100644 --- a/modules/companies/src/web/companies/detail/ui/company-section.tsx +++ b/modules/companies/src/web/companies/detail/ui/company-section.tsx @@ -1,6 +1,7 @@ import { AssetCatalogPage, InfrastructurePage } from "@metis/assets/client"; import { useBrowserSearchParams, useCreateNavigation } from "@metis/core/client"; import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client"; +import type { ComponentProps } from "react"; import type { CompanyDTO } from "../../../../common"; import { AccessWorkspacePage } from "../../../access"; @@ -29,9 +30,11 @@ export function CompanySection({ initialEntityId, initialCreate, initialConfiguration, + peopleDataState, }: { company: CompanyDTO; tab: CompanyTab; + peopleDataState?: ComponentProps["dataState"]; initialEntityId?: string | undefined; initialCreate?: boolean | undefined; initialConfiguration?: @@ -61,7 +64,9 @@ export function CompanySection({ case "telework": return ; case "people": - return ; + return ( + + ); case "departments": return ; case "sites": diff --git a/modules/companies/src/web/people/index.ts b/modules/companies/src/web/people/index.ts index f383a21..244de71 100644 --- a/modules/companies/src/web/people/index.ts +++ b/modules/companies/src/web/people/index.ts @@ -1,3 +1,3 @@ +export type { PeopleListDataState } from "./list"; export { PeopleSection } from "./list"; - -export { PersonEditorDialog, PersonForm, PersonAvatarGroup } from "./shared"; +export { PersonAvatarGroup, PersonEditorDialog, PersonForm } from "./shared"; diff --git a/modules/companies/src/web/people/list/controllers/use-people-list-context.ts b/modules/companies/src/web/people/list/controllers/use-people-list-context.ts new file mode 100644 index 0000000..0d756dc --- /dev/null +++ b/modules/companies/src/web/people/list/controllers/use-people-list-context.ts @@ -0,0 +1,119 @@ +import { + navigateTo, + returnToList, + useBrowserSearchParams, + useEntityNavigation, + useListLocation, + useListReturn, +} from "@metis/core/client"; +import { useEffect, useRef, useState } from "react"; + +export type PeopleListDataState = "pending" | "ready" | "unverified"; + +/** Consulta navegable de Personas y fallback local de los consumidores embebidos. */ +export function usePeopleListContext( + companyId: string, + initialId: string | undefined, + dataState: PeopleListDataState +) { + const { + selectedId, + selectEntity, + entityHref: baseEntityHref, + routed, + } = useEntityNavigation(`/companies/${companyId}/people`, initialId); + const [localSearch, setLocalSearch] = useState(""); + const [localContactsOnly, setLocalContactsOnly] = useState(false); + const { searchParams, setSearchParams } = useBrowserSearchParams(); + const search = routed ? (searchParams.get("q") ?? "") : localSearch; + const contactsOnly = routed ? searchParams.get("contactsOnly") === "true" : localContactsOnly; + const rootRef = useRef(null); + const navigation = useListLocation( + { sort: "sort", direction: "direction", page: "page" }, + dataState === "ready" + ); + + useListReturn({ + scope: `/companies/${companyId}/people`, + rootRef, + ready: dataState !== "pending", + recordFocus: dataState === "ready", + }); + + const entityHref = (id: string) => + `${baseEntityHref(id)}${searchParams.size ? `?${searchParams.toString()}` : ""}`; + + function setSearch(value: string) { + if (!routed) { + setLocalSearch(value); + + return; + } + setSearchParams((params) => { + params.delete("page"); + if (value) { + params.set("q", value); + } else { + params.delete("q"); + } + }); + } + + function setContactsOnly(value: boolean) { + if (!routed) { + setLocalContactsOnly(value); + + return; + } + setSearchParams((params) => { + params.delete("page"); + if (value) { + params.set("contactsOnly", "true"); + } else { + params.delete("contactsOnly"); + } + }); + } + + useEffect(() => { + if ( + routed && + searchParams.has("contactsOnly") && + (searchParams.get("contactsOnly") !== "true" || + searchParams.getAll("contactsOnly").length > 1) + ) { + setSearchParams((params) => { + if (contactsOnly) { + params.set("contactsOnly", "true"); + } else { + params.delete("contactsOnly"); + } + }); + } + }, [routed, searchParams, contactsOnly, setSearchParams]); + + function selectPerson(id: string | undefined) { + if (!routed) { + selectEntity(id); + } else if (id) { + navigateTo(entityHref(id)); + } else { + returnToList( + `/companies/${companyId}/people${searchParams.size ? `?${searchParams.toString()}` : ""}` + ); + } + } + + return { + selectedId, + entityHref, + routed, + search, + setSearch, + contactsOnly, + setContactsOnly, + rootRef, + navigation, + selectPerson, + }; +} diff --git a/modules/companies/src/web/people/list/index.ts b/modules/companies/src/web/people/list/index.ts index d89e4df..ba569ba 100644 --- a/modules/companies/src/web/people/list/index.ts +++ b/modules/companies/src/web/people/list/index.ts @@ -1 +1,2 @@ +export type { PeopleListDataState } from "./controllers/use-people-list-context"; export { PeopleSection } from "./ui"; diff --git a/modules/companies/src/web/people/list/ui/people-section.tsx b/modules/companies/src/web/people/list/ui/people-section.tsx index f2cec7c..0ce38f0 100644 --- a/modules/companies/src/web/people/list/ui/people-section.tsx +++ b/modules/companies/src/web/people/list/ui/people-section.tsx @@ -1,11 +1,5 @@ import { inventoryApi, inventoryKeys } from "@metis/assets/client"; -import { - BrowserLink, - EntityNotFound, - navigateTo, - useEntityNavigation, - useListQuery, -} from "@metis/core/client"; +import { BrowserLink, EntityNotFound, navigateTo, useListQuery } from "@metis/core/client"; import { EntityAction, EntityListHeader, @@ -25,13 +19,17 @@ import type { CompanyDTO } from "../../../../common"; import { personWorkstations } from "../../../workstations/detail/helpers/person-workstations"; import { PersonDetails } from "../../detail"; import { PersonAvatar, PersonEditorDialog } from "../../shared"; +import type { PeopleListDataState } from "../controllers/use-people-list-context"; +import { usePeopleListContext } from "../controllers/use-people-list-context"; export function PeopleSection({ company, initialId, + dataState = "ready", }: { company: CompanyDTO; initialId?: string | undefined; + dataState?: PeopleListDataState | undefined; }) { const { t, i18n } = useTranslation("companies"); const { t: assetTranslation } = useTranslation("assets"); @@ -39,12 +37,18 @@ export function PeopleSection({ const [selection, setSelection] = useState< { mode: "new" | "detail" | "edit"; id?: string } | undefined >(initialId ? { mode: "detail", id: initialId } : undefined); - const { selectedId, selectEntity, entityHref, routed } = useEntityNavigation( - `/companies/${company.id}/people`, - initialId - ); - const [search, setSearch] = useState(""); - const [contactsOnly, setContactsOnly] = useState(false); + const { + selectedId, + entityHref, + routed, + search, + setSearch, + contactsOnly, + setContactsOnly, + rootRef, + navigation, + selectPerson, + } = usePeopleListContext(company.id, initialId, dataState); const selected = company.people.find((item) => item.id === (routed ? selectedId : selection?.id)); const inventory = useListQuery({ queryKey: inventoryKeys.company(company.id), @@ -65,7 +69,7 @@ export function PeopleSection({ }} onSaved={(id) => { setSelection({ mode: "detail", id }); - selectEntity(id); + selectPerson(id); }} open={selection?.mode === "new" || selection?.mode === "edit"} person={selected} @@ -84,7 +88,7 @@ export function PeopleSection({ key={selected.id} onBack={() => { setSelection(undefined); - selectEntity(undefined); + selectPerson(undefined); }} onEdit={() => setSelection({ mode: "edit", id: selected.id })} person={selected} @@ -99,7 +103,7 @@ export function PeopleSection({ ); return ( -
+
{personEditor}