diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..341df9a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.fontFamily": "'C64 Keyboard Regular', 'Fira Code Retina', 'Fira Code'" +} diff --git a/AGENTS.md b/AGENTS.md index caed3f0..8247854 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,11 @@ Estado remoto en TanStack Query; claves centralizadas e invalidación explícita No duplicar estado remoto ni usar efectos para derivar valores calculables durante render. Mantener accesibilidad y estados de carga, error, vacío y acciones deshabilitadas. Separar componentes por responsabilidades reales, sin fragmentar JSX trivial. +Antes de crear o modificar pantallas, reconocer activamente patrones visuales y de interacción comunes entre ellas, así como componentes repetidos dentro de una misma pantalla. Aplicar soluciones coherentes a todos los casos equivalentes, conservando las necesidades propias de cada entidad. +Reutilizar y extender la biblioteca visual existente antes de duplicar estructura, estilos o comportamiento. Extraer componentes compartidos cuando haya repetición real: cabeceras, listados, formularios modales, grupos de campos, selectores con alta, estados y ayuda contextual. +Construir progresivamente una biblioteca visual básica con contratos claros, accesibilidad, textos traducibles y composición sencilla. Mantener los componentes genéricos independientes de entidades, DTO, consultas y endpoints de Metis Desk; el módulo consumidor aporta datos, textos y acciones. Documentar patrones y componentes en docs/componentes-de-interfaz.md y en el estándar correspondiente. +Preparar esa independencia para poder agrupar o extraer posteriormente componentes en un paquete reutilizable en otras aplicaciones. Aprovechar @repo/shadcn-ui y crear abstracciones conforme aparezcan consumidores reales. +Las pantallas de listado de entidades siguen [el patrón de listados](docs/patron-de-listados-de-entidades.md), reutilizando los componentes comunes de @repo/shadcn-ui. Los listados no muestran formularios de alta ni edición junto a los datos. El alta se inicia con un botón en la cabecera, a la derecha; formularios en modal shadcn/ui como norma general, con anchura y desplazamiento adaptados a su contenido. La edición se abre mediante una acción explícita, siguiendo el mismo criterio. Mantener foco, cierre por teclado, cancelación y errores dentro del formulario. Incluir ayuda contextual con finalidad, ámbito y ejemplos, sin rellenar los campos automáticamente. En formularios, colocar la ayuda en la parte inferior, inmediatamente antes del pie del modal con Guardar y Cancelar. ## Validación y entrega diff --git a/apps/web/src/global.css b/apps/web/src/global.css index bf68a89..54a76d7 100644 --- a/apps/web/src/global.css +++ b/apps/web/src/global.css @@ -117,6 +117,8 @@ body { } .asset-side-panel { display: grid; + grid-template-columns: minmax(0, 1fr); + overflow-wrap: anywhere; gap: 24px; padding: 20px; min-width: 0; diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index a9ecb91..6e59405 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -89,3 +89,15 @@ Uso de cuenta, estado de configuración e idioma son listas cerradas. Las refere El catálogo de activos utiliza SelectWithAction para añadir campos a una plantilla y crearlos mediante un modal adicional. Los tipos de dato constituyen una lista cerrada. Las definiciones de serie se consultan en modales de lectura; su personalización se realiza mediante copias. El inventario añade altas rápidas de plantilla, sede y persona en los activos. La plantilla creada desde un activo solo ofrece campos existentes para mantener un único nivel adicional de modal. Las características utilizan controles según su tipo; los valores sí/no conservan un estado vacío distinto de No. + +## Listados de entidades + +El estándar se define en [Patrón de listados de entidades](patron-de-listados-de-entidades.md), con inventario de pantallas y excepciones. EntityListHeader, EntityTable, EntityRowActions, EntityAction y EntityStatus viven en @repo/shadcn-ui/components/entity-list. + +EntityTable recibe datos y columnas tipadas; utiliza TanStack Table para búsqueda, ordenación, visibilidad y paginación local. Admite búsqueda y filtros controlados por el módulo, conservando su semántica y ámbito. Las columnas de identificación y acciones quedan visibles. Los menús utilizan Base UI y conservan los callbacks de las acciones originales. No realiza peticiones ni conoce DTO de negocio. + +La paginación es local sobre los conjuntos que las API actuales ya devuelven. No simula paginación de servidor. La selección del activo para el panel sigue perteneciendo a Inventario, fuera de la tabla compartida. + +EntityTable separa internamente la configuración de columnas/ordenación, el contenido semántico y la paginación; useEntityTable concentra el estado de representación. entityLabel permite contar «plantillas», «campos» o «tickets» aunque el título del espacio sea más amplio. Los tamaños disponibles son 10, 25, 50 y 100; una opción vacía nunca cambia el tamaño a cero. + +Se declara @tanstack/react-table en el paquete compartido porque es quien consume ahora la librería; se reutiliza la versión 8.21.3 que ya utilizaba Compañías. No se introduce un segundo sistema de tablas. diff --git a/docs/patron-de-listados-de-entidades.md b/docs/patron-de-listados-de-entidades.md new file mode 100644 index 0000000..681488f --- /dev/null +++ b/docs/patron-de-listados-de-entidades.md @@ -0,0 +1,111 @@ +# Patrón de listados de entidades + +Estándar de Metis Desk. Fuente funcional: patrón acordado con el usuario en esta conversación. La referencia de Activos conserva el estilo Vega, los tokens semánticos y la ficha lateral real de la aplicación. + +## Estructura obligatoria + +La pantalla se divide en cabecera, contenido y pie opcional. La cabecera muestra título y subtítulo breve a la izquierda y acciones globales a la derecha. El alta es la acción primaria, normalmente «+ Añadir [entidad]», y abre un modal; se conservan las etiquetas actuales cuando ya describen una operación concreta. Nunca mostrar el formulario junto al listado. + +El contenido contiene una tabla y cuatro bandas, en este orden: + +1. Búsqueda rápida a la izquierda y filtros propios de la entidad a la derecha. El texto indica los campos buscables. Cuando hay criterios aplicados, Limpiar filtros restaura los valores iniciales de la pantalla; no cambia el ámbito de compañía ni el perfil desde el que se ha navegado. +2. Información «Mostrando X-Y de N [entidades]» a la izquierda; Columnas y Ordenar por a la derecha. Estos controles cambian la representación, no los filtros ni los datos. +3. Cabeceras y filas de la tabla, con densidad uniforme. Los estados vacíos siguen dentro de esta banda. +4. Información del mismo rango repetida a la izquierda; anterior, páginas, siguiente y elementos por página a la derecha. + +El pie opcional está fuera de la tabla y solo aparece cuando existe contenido complementario útil. No añadir estadísticas ni decoración sin una necesidad funcional. + +## Reglas de interacción + +- Búsqueda y filtros se combinan; filtrar o cambiar tamaño de página vuelve a la primera página. Si una actualización reduce resultados, se ajusta la página para no mostrar un rango inexistente. +- Contar los resultados filtrados antes de paginar. Cero resultados se expresa como 0-0 de 0. La ordenación actúa sobre todos los resultados filtrados antes de paginar y mantiene un orden estable para empates. +- Columnas configurables mediante controles con etiquetas; identificación y acciones no se ocultan. No se pierde acceso a una acción por ocultar otra columna. La configuración es local a la vista y no se guarda en datos de negocio. +- Solo ofrecer ordenación para valores con significado comparable. Cabeceras ordenables son botones accesibles y expresan aria-sort. Permitir recuperar el orden inicial, respetando los órdenes existentes, por ejemplo última actualización en tickets e intervenciones. +- El identificador principal abre la ficha existente si hay una. Usar enlace si existe una ruta real; botón si abre diálogo, detalle en memoria o panel. No inventar rutas. +- Representar estados mediante indicador visual y texto, nunca solo color. Mantener los avisos de revisión e información contextual. +- Las relaciones son navegables cuando el destino ya ofrece navegación adecuada; no introducir enlaces ficticios ni nuevas rutas. +- Acciones de fila al final, con el menú contextual accesible y las restricciones existentes. No convertir acciones de negocio en acciones masivas. +- Selección múltiple solo cuando una operación real la necesita. No añadir casillas sin consumidor. Mantener separada la selección del activo para su panel lateral. +- Cancelar un modal conserva búsqueda, filtros, página y selección de la lista. Las actualizaciones continúan invalidando las consultas actuales. + +## Común y específico + +Común: distribución, cabecera, cuatro bandas, márgenes, altura base de filas, búsqueda, limpieza, contador, columnas, ordenación, paginación, accesibilidad y estados. + +Específico: título, subtítulo, ámbito, alta y acciones globales, campos buscables, filtros, columnas, formatos, acciones de fila, avisos y destino de ficha. Las columnas conservan la información funcional de cada pantalla. Licencias y aplicaciones continúan dentro del listado Recursos; no crear pantallas para entidades que no existen. + +## Responsive, accesibilidad y estados + +Cabecera y bandas se apilan en anchuras pequeñas. Los filtros ajustan sus controles, la tabla permite desplazamiento horizontal dentro de su contenedor y las celdas largas pueden partir texto. Mantener la navegación del espacio de compañía y el panel de activos en escritorio, con la ficha modal móvil existente. + +Usar tabla semántica con nombre accesible, th con scope, controles etiquetados, foco visible, acciones por teclado y página actual con aria-current. Los elementos decorativos se ocultan al lector. Los menús y modales conservan manejo de foco de Base UI. No depender del color para comunicar información. + +Carga inicial y errores se presentan dentro del contenido sin afirmar que no hay registros. El vacío distingue ausencia de datos y búsqueda sin resultados. Mantener el mensaje y la recuperación de errores de cada consulta; no inventar resultados ni endpoints. Las recargas conservan datos y controles cuando la integración existente lo permite. + +## Inventario previo a la migración + +La aplicación navega por espacios y secciones en memoria, no mediante rutas URL independientes. Los componentes siguientes se alcanzan desde CompaniesWorkspace, CompanyDetail o IdentityWorkspace. + +| Listado / ámbito | Componente actual | Aplicación | +| --- | --- | --- | +| Compañías | companies/components/companies-page.tsx | Sí | +| Personas/contactos | companies/components/people-section.tsx, PeopleSection | Sí | +| Departamentos | companies/components/department-section.tsx | Sí | +| Sedes | companies/components/company-detail.tsx, SiteSection | Sí | +| Usuarios internos | identity/components/user-management.tsx | Sí | +| Activos | assets/inventory-page.tsx | Sí; conserva panel lateral | +| Puestos de trabajo | assets/workstations-page.tsx | Sí | +| Recursos, incluidas aplicaciones y licencias | resources/resources-page.tsx | Sí | +| Campos y plantillas de recursos, global/compañía | resources/catalog-page.tsx | Sí; dos secciones | +| Campos, plantillas y relaciones de activos, global/compañía | assets/catalog-page.tsx | Sí; tres secciones | +| Cuentas y accesos / catálogos informáticos | companies/access/access-workspace.tsx | Sí; cuenta, titularidad, grupo, pertenencia, autorización, necesidad, cobertura, entorno, sistema, tipo, permiso y plantilla | +| Perfiles de usuario | companies/profiles/work-profiles-page.tsx | Sí | +| Correo por perfil | companies/mail/mail-configurations-page.tsx | Sí, también entrada desde perfil | +| Carpetas por perfil | companies/folders/folder-mappings-page.tsx | Sí, también entrada desde perfil | +| Impresoras por perfil | companies/printers/printer-configurations-page.tsx | Sí, también entrada desde perfil | +| Aplicaciones por perfil | companies/applications/application-configurations-page.tsx | Sí, también entrada desde perfil | +| Certificados por perfil | companies/certificates/certificate-configurations-page.tsx | Sí, también entrada desde perfil | +| Teletrabajo | companies/telework/telework-authorizations-page.tsx | Sí, conserva ámbito de persona | +| Accesos remotos | companies/remote/remote-connections-page.tsx | Sí, conserva ámbito de persona | +| Procesos de personas | companies/processes/processes-page.tsx | Sí; su detalle es excepción | +| Plantillas de procesos | companies/processes/process-templates-page.tsx | Sí | +| Plantillas de intervención, global/compañía | companies/intervention-templates/templates-page.tsx | Sí | +| Tickets, global/compañía | companies/tickets/tickets-page.tsx | Sí | +| Intervenciones, global/compañía | companies/interventions/interventions-page.tsx | Sí | +| Herramientas de asistencia | assets/support-tools.tsx | Sí; catálogo dentro de un modal | +| Asignaciones departamentales | PeopleSection → PersonDetails | Sublistado de ficha; conserva sus operaciones y adopta tabla común | + +Rutas relativas anteriores bajo modules/[módulo]/src/web. No hay listados separados de equipos, software, incidencias o ubicaciones adicionales: corresponden a Activos, Recursos, Tickets o Sedes. + +## Excepciones justificadas + +No son pantallas de listado sencillo: HomePage (estado técnico), WorkEnvironmentPage (resumen de preparación), fichas CompanyDetail/PersonDetails/AssetDetail/ResourceDetail/TicketDetail/InterventionDetail, historiales y revisiones, pasos/instrucciones de plantillas, tareas de ProcessDetail y selectores de formularios. + +AssetRelationships, AssetSupport, MailboxRelations y TicketInterventions son bloques contextuales de ficha, sin gestión independiente de un conjunto; mantienen su presentación contextual. CompanyInventory es un adaptador de consultas, no un listado adicional. Las barras laterales, navegación, formulario de acceso y cambio de contraseña tampoco adoptan el patrón. + +Las instrucciones/historiales que antes estaban expandidos dentro de catálogos se mantienen accesibles desde cada fila; no desaparecen por pasar a tabla. + +## Implementación y verificación + +Se reutilizan Table, Button, Input, SelectWithAction, Dialog y los tokens existentes de @repo/shadcn-ui. Las consultas, DTO, endpoints, permisos y mutaciones permanecen en sus módulos. Los componentes comunes no conocen entidades ni invocan API. + +La validación debe incluir tipos, estilo, pruebas y compilación, además de interacción real para búsqueda/limpieza, columnas, ordenación, cambios de página/tamaño, apertura/cancelación de fichas y panel de activos. Verificar español e inglés y una anchura móvil. + +### Migración realizada + +Se han adaptado las 25 familias de listados del inventario y el sublistado de asignaciones departamentales. Los catálogos y accesos reutilizan sus componentes para las distintas secciones y ámbitos; no son nuevas rutas ni nuevas entidades. El inventario conserva la ficha lateral y su modal móvil. Las fichas, historiales y bloques contextuales de las excepciones mantienen su función. + +Componentes compartidos: EntityListHeader y EntityTable; EntityTableConfiguration para columnas y orden; EntityTableContent para tabla semántica; EntityListPagination, EntityRowActions, EntityAction y EntityStatus para controles. useEntityTable concentra filtros locales, ordenación estable, columnas visibles y paginación. entity-list.ts contiene textos es/en y cálculo de rangos. No se añade selección masiva sin una operación que la consuma. + +La primera columna tiene anchura mínima para evitar nombres ilegibles. El desplazamiento horizontal permanece dentro de la tabla. El panel de activos ajusta su contenido y sus botones a la anchura disponible. + +### Verificación de la entrega (7 de octubre de 2026) + +- pnpm typecheck: correcto, 10 tareas. +- pnpm test: correcto, 195 pruebas, incluidas HTTP/MariaDB en la base de pruebas; 9 tareas. Las nuevas pruebas cubren límites de paginación, rangos repetidos, búsqueda local/controlada, traducción y distinción de carga/error/vacío. +- pnpm build: correcto, 2 tareas. Se mantiene el aviso de tamaño del paquete web. +- pnpm lint y pnpm check: ejecutados; siguen fallando por problemas previos ajenos a la migración. La comprobación específica de todos los archivos de código modificados y nuevos es correcta. No se han añadido supresiones. +- Navegador: revisadas todas las familias de pantallas y las secciones de cuentas/accesos; búsqueda, filtros específicos y limpieza, columnas bloqueadas/configurables, ordenación y recuperación del orden inicial, segunda página, tamaño de página y opción vacía. +- Navegación comprobada a persona, ticket y activo; edición/copia en modal y cancelación sin guardar. Las definiciones de serie no ofrecen edición y las plantillas globales no ofrecen edición desde una compañía. +- Español/inglés y anchura móvil de 390 px comprobados; el activo abre la ficha modal en móvil. En escritorio la página no presenta desbordamiento horizontal; la tabla conserva su propio desplazamiento. +- No se han creado ni alterado registros de negocio durante la revisión del navegador. diff --git a/modules/assets/src/web/asset-support.tsx b/modules/assets/src/web/asset-support.tsx index 04da6ea..adbe402 100644 --- a/modules/assets/src/web/asset-support.tsx +++ b/modules/assets/src/web/asset-support.tsx @@ -37,7 +37,7 @@ export function AssetSupport({

{t("support.title")}

-
+
@@ -88,7 +88,7 @@ export function AssetSupport({ (retired || !tools.find((tool) => tool.id === item.values.toolId)?.active) && (

{t("support.review")}

)} -
+
-
+ + + + } + description={t(scope === "global" ? "introduction" : "companyIntroduction")} + locale={i18n.language} + title={t(scope === "global" ? "title" : "companyTitle")} + />
); } + +function CatalogName({ + item, + items, + onView, +}: { + item: CatalogItemDTO; + items: readonly CatalogItemDTO[]; + onView: () => void; +}) { + const { t } = useTranslation("assets"); + const values = item.values; + const inactiveFields = + values.kind === "template" && + values.fields.some( + (field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive + ); + return ( + <> + + {values.description &&

{values.description}

} + {inactiveFields && ( +

{t("disabledFieldsNotice")}

+ )} + + ); +} + +function CatalogDetails({ item }: { item: CatalogItemDTO }) { + const { t } = useTranslation("assets"); + const values = item.values; + if (values.kind === "field") { + return t(values.type); + } + if (values.kind === "relation") { + return values.inverseName; + } + return t("fieldCount", { count: values.fields.length }); +} diff --git a/modules/assets/src/web/catalog.test.tsx b/modules/assets/src/web/catalog.test.tsx index 6babd6b..de01587 100644 --- a/modules/assets/src/web/catalog.test.tsx +++ b/modules/assets/src/web/catalog.test.tsx @@ -40,15 +40,15 @@ async function render(language: string, children: ReactNode) { } } test("el catálogo muestra plantillas de serie y acción de alta sin formularios junto al listado", async () => { - for (const [language, create, copy] of [ - ["es", "Nueva plantilla", "Crear copia"], - ["en", "New template", "Create copy"], + for (const [language, create, actions] of [ + ["es", "Nueva plantilla", "Acciones"], + ["en", "New template", "Actions"], ]) { - assert.ok(language && create && copy); + assert.ok(language && create && actions); const html = await render(language, ); assert.ok(html.includes("Portátil")); assert.ok(html.includes(create)); - assert.ok(html.includes(copy)); + assert.ok(html.includes(actions)); assert.equal(html.includes("Editar"), false); } diff --git a/modules/assets/src/web/inventory-page.tsx b/modules/assets/src/web/inventory-page.tsx index 07f0fd1..cdfa39e 100644 --- a/modules/assets/src/web/inventory-page.tsx +++ b/modules/assets/src/web/inventory-page.tsx @@ -1,7 +1,12 @@ import { Button } from "@repo/shadcn-ui/components/button"; -import { Card, CardContent } from "@repo/shadcn-ui/components/card"; +import { + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/shadcn-ui/components/entity-list"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { Input } from "@repo/shadcn-ui/components/input"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -25,7 +30,7 @@ export function AssetInventoryPage({ csrfToken: string; lookups: InventoryLookups; }) { - const { t } = useTranslation("assets"); + const { t, i18n } = useTranslation("assets"); const query = useQuery({ queryKey: inventoryKeys.company(companyId), queryFn: () => inventoryApi.get(companyId), @@ -57,29 +62,30 @@ export function AssetInventoryPage({ return (
-
-

{t("inventory")}

-
- {!detailOpen && ( - - )} - -
-
- setSearch(event.target.value)} - placeholder={t("search")} - value={search} + + {" "} +
+ {!detailOpen && ( + + )} + +
{" "} + + } + locale={i18n.language} + title={t("inventory")} /> + {(query.isPending || catalog.isPending) &&

{t("loading")}

} @@ -94,68 +100,103 @@ export function AssetInventoryPage({ )} {query.data && ( - - - {!filtered.length && ( -

- {t(assets.length ? "noResults" : "noAssets")} -

- )} - {filtered.map((asset) => ( -
-
-

- + ), + }, + { + id: "template", + label: t("template"), + value: (asset) => asset.specification.templateName, + render: (asset) => <>{asset.specification.templateName}, + }, + { + id: "state", + label: t("status"), + value: (asset) => t(asset.values.status), + render: (asset) => ( + + {t(asset.values.status)} + + ), + }, + { + id: "site", + label: t("site"), + value: (asset) => + lookups.sites.find((site) => site.id === asset.values.siteId)?.name ?? + t("noAssignment"), + render: (asset) => ( + <> + {lookups.sites.find((site) => site.id === asset.values.siteId)?.name ?? + t("noAssignment")} + + ), + }, + { + id: "person", + label: t("person"), + value: (asset) => + lookups.people.find((person) => person.id === asset.values.personId)?.name ?? + t("noAssignment"), + render: (asset) => ( + <> + {lookups.people.find((person) => person.id === asset.values.personId)?.name ?? + t("noAssignment")} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (asset) => ( + + {" "} +
+ setViewId(asset.id)} > - {asset.values.name} - -

-

- {asset.specification.templateName} · {t(asset.values.status)} -

-

- {lookups.sites.find((site) => site.id === asset.values.siteId)?.name ?? - t("noAssignment")}{" "} - ·{" "} - {lookups.people.find((person) => person.id === asset.values.personId)?.name ?? - t("noAssignment")} -

-
-
- - -
-
- ))} -
-
+ {t("viewAsset")} + + setEditor(asset.id)}> + {t("edit")} + +
{" "} + + ), + }, + ]} + emptyMessage={t("noAssets")} + items={filtered} + locale={i18n.language} + onSearchChange={setSearch} + rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)} + search={search} + searchPlaceholder={t("search")} + title={t("inventory")} + /> )}
supportApi.tools() }); const [edit, setEdit] = useState(); const [open, setOpen] = useState(false); return (
-
- -
+ + {" "} + {" "} + + } + locale={i18n.language} + title={t("support.tools")} + /> {query.isPending &&

{t("support.loading")}

} {query.isError && (
@@ -39,25 +53,55 @@ export function SupportTools({ csrfToken }: { csrfToken: string }) {
)} -
    - {query.data?.tools.map((tool) => ( -
  • - - {tool.name} · {t(tool.active ? "support.active" : "inactive")} - {tool.builtin ? ` · ${t("support.builtin")}` : ""} - - -
  • - ))} -
+ tool.name, + hideable: false, + render: (tool) => {tool.name}, + }, + { + id: "state", + label: t("status"), + value: (tool) => tool.active, + render: (tool) => ( + + {t(tool.active ? "support.active" : "inactive")} + + ), + }, + { + id: "origin", + label: t("origin"), + value: (tool) => tool.builtin, + render: (tool) => <>{t(tool.builtin ? "support.builtin" : "custom")}, + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (tool) => ( + + { + setEdit(tool); + setOpen(true); + }} + > + {t("edit")} + + + ), + }, + ]} + items={query.data?.tools ?? []} + loading={query.isPending} + locale={i18n.language} + title={t("support.tools")} + unavailable={query.isError} + /> value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) ) ); - const summary = (item: WorkstationDTO) => ( + const _summary = (item: WorkstationDTO) => ( <>

{item.values.name} · {t(item.values.active ? "active" : "inactive")} @@ -66,18 +71,19 @@ export function WorkstationsPage({ ); return (
-
-

{t("workstations")}

- -
- setSearch(event.target.value)} - placeholder={t("search")} - value={search} + + {" "} + {" "} + + } + locale={i18n.language} + title={t("workstations")} /> + {query.isPending &&

{t("loading")}

} {query.isError && ( @@ -91,28 +97,94 @@ export function WorkstationsPage({ )} {query.data && ( - - - {!filtered.length && ( -

- {t(stations.length ? "noResults" : "noWorkstations")} -

- )} - {filtered.map((item) => ( -
-
{summary(item)}
-
- - -
-
- ))} -
-
+ item.values.name, + hideable: false, + render: (item) => ( + + ), + }, + { + id: "site", + label: t("site"), + value: (item) => siteName(item.values.siteId), + render: (item) => <>{siteName(item.values.siteId)}, + }, + { + id: "person", + label: t("person"), + value: (item) => personName(item.values.personId), + render: (item) => <>{personName(item.values.personId)}, + }, + { + id: "state", + label: t("status"), + value: (item) => t(item.values.active ? "active" : "inactive"), + render: (item) => ( + + {t(item.values.active ? "active" : "inactive")} + + ), + }, + { + id: "exclusive", + label: t("exclusiveWorkstationAssets"), + render: (item) => <>{members(item.values.assetIds)}, + }, + { + id: "shared", + label: t("sharedWorkstationAssets"), + render: (item) => ( + <>{members(item.values.sharedAssetIds, "noSharedWorkstationAssets")} + ), + }, + { + id: "count", + label: t("characteristics"), + value: (item) => item.values.assetIds.length + item.values.sharedAssetIds.length, + render: (item) => ( + <> + {t("workstationCount", { + count: item.values.assetIds.length + item.values.sharedAssetIds.length, + })} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + {" "} +
+ setView(item.id)}> + {t("workstationHistory")} + + setEditor(item.id)}>{t("edit")} +
{" "} +
+ ), + }, + ]} + emptyMessage={t("noWorkstations")} + items={filtered} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("search")} + title={t("workstations")} + /> )} {editor && ( -

{state}

+ + {state} + {"configuration" in value && value.configuration && (

{t(value.configuration)}

)} @@ -35,7 +42,8 @@ function EntryState({ value }: { value: AccessFieldsDTO }) { ); } -export function AccessRow({ +export function AccessCell({ + column, item, data, scope, @@ -43,6 +51,7 @@ export function AccessRow({ onEdit, onHistory, }: { + column: "name" | "state" | "review" | "actions"; item: AccessItemDTO; data: AccessResponseDTO; scope: string; @@ -60,40 +69,55 @@ export function AccessRow({ return permission?.kind === "permission" ? permission.name : "—"; }) .join(", "); - return ( - - -

{accessLabel(item, data.items, people)}

- {!own &&

{t("global")}

} -
- - - {coverage &&

{t(coverage.covered ? "covered" : "notCovered")}

} - {coverage && !coverage.covered && missing && ( -

- {t("missingPermissions")}: {missing} -

- )} -
- - {reviews.length - ? reviews.map((review) => ( -

- {t(`reviews.${review.reason}`)} -

- )) - : "—"} -
- -
- - -
-
-
- ); + switch (column) { + case "name": + return ( + <> + {" "} +

{accessLabel(item, data.items, people)}

+ {!own &&

{t("global")}

}{" "} + + ); + case "state": + return ( + <> + {" "} + + {coverage &&

{t(coverage.covered ? "covered" : "notCovered")}

} + {coverage && !coverage.covered && missing && ( +

+ {t("missingPermissions")}: {missing} +

+ )}{" "} + + ); + case "review": + return ( + <> + {" "} + {reviews.length + ? reviews.map((review) => ( +

+ {t(`reviews.${review.reason}`)} +

+ )) + : "—"}{" "} + + ); + case "actions": + return ( + <> + {" "} + + {" "} +
+ + {t("edit")} + + {t("history")} +
{" "} +
{" "} + + ); + } } diff --git a/modules/companies/src/web/access/access-workspace.tsx b/modules/companies/src/web/access/access-workspace.tsx index 03bea32..8e8a8da 100644 --- a/modules/companies/src/web/access/access-workspace.tsx +++ b/modules/companies/src/web/access/access-workspace.tsx @@ -1,15 +1,6 @@ import { Button } from "@repo/shadcn-ui/components/button"; -import { Card, CardContent } from "@repo/shadcn-ui/components/card"; +import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { Input } from "@repo/shadcn-ui/components/input"; -import { Label } from "@repo/shadcn-ui/components/label"; -import { - Table, - TableBody, - TableHead, - TableHeader, - TableRow, -} from "@repo/shadcn-ui/components/table"; import { useQuery } from "@tanstack/react-query"; import { useId, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -21,7 +12,7 @@ import { accessFields, createLabels } from "./access-fields"; import { AccessForm } from "./access-form"; import { AccessHistory } from "./access-history"; import { accessLabel } from "./access-labels"; -import { AccessRow } from "./access-row"; +import { AccessCell } from "./access-row"; import { EntityHelp } from "./entity-help"; export function AccessWorkspacePage({ @@ -33,7 +24,7 @@ export function AccessWorkspacePage({ people?: readonly PersonDTO[]; csrfToken: string; }) { - const { t } = useTranslation("access"); + const { t, i18n } = useTranslation("access"); const query = useQuery({ queryKey: accessKeys.scope(scope), queryFn: () => accessApi.request(scope), @@ -43,7 +34,7 @@ export function AccessWorkspacePage({ const [history, setHistory] = useState(); const [helpOpen, setHelpOpen] = useState(false); const [search, setSearch] = useState(""); - const searchId = useId(); + const _searchId = useId(); const data = query.data; const selected = data?.items.find((item) => item.id === editor); const historical = data?.items.find((item) => item.id === history); @@ -63,27 +54,35 @@ export function AccessWorkspacePage({ "resource_type", "permission", ]; + const visible = + data?.items.filter( + (item) => + item.values.kind === kind && + accessLabel(item, data.items, people) + .toLocaleLowerCase() + .includes(search.trim().toLocaleLowerCase()) + ) ?? []; const complex = accessFields[kind].length > 5; return (
-
-
-

- {t(scope === "global" ? "catalogTitle" : "title")} -

-

- {t(scope === "global" ? "catalogHelp" : "workspaceHelp")} -

-
-
- - -
-
+ + {" "} +
+ + +
{" "} + + } + description={t(scope === "global" ? "catalogHelp" : "workspaceHelp")} + locale={i18n.language} + title={t(`kinds.${kind}`)} + /> -
- - setSearch(event.target.value)} - value={search} - /> -
- - - - - - {t("name")} - {t("state")} - {t("review")} - {t("actions")} - - - - {data.items - .filter( - (item) => - item.values.kind === kind && - accessLabel(item, data.items, people) - .toLocaleLowerCase() - .includes(search.trim().toLocaleLowerCase()) - ) - .map((item) => ( - setEditor(item.id)} - onHistory={() => setHistory(item.id)} - people={people} - scope={scope} - /> - ))} - -
- {!data.items.some((item) => item.values.kind === kind) && ( -

{t("empty")}

- )} -
-
+ accessLabel(item, data.items, people), + hideable: false, + render: (item) => ( + setEditor(item.id)} + onHistory={() => setHistory(item.id)} + people={people} + scope={scope} + /> + ), + }, + { + id: "state", + label: t("state"), + render: (item) => ( + setEditor(item.id)} + onHistory={() => setHistory(item.id)} + people={people} + scope={scope} + /> + ), + }, + { + id: "review", + label: t("review"), + render: (item) => ( + setEditor(item.id)} + onHistory={() => setHistory(item.id)} + people={people} + scope={scope} + /> + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + setEditor(item.id)} + onHistory={() => setHistory(item.id)} + people={people} + scope={scope} + /> + ), + }, + ]} + emptyMessage={t("empty")} + items={visible} + key={kind} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("search")} + title={t(`kinds.${kind}`)} + /> (); const [view, setView] = useState(); const [search, setSearch] = useState(""); @@ -71,18 +76,19 @@ export function ApplicationConfigurationsPage({ const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true; return (
-
-

{t("applications.title")}

- -
- setSearch(event.target.value)} - placeholder={t("applications.search")} - value={search} + + {" "} + {" "} + + } + locale={i18n.language} + title={t("applications.title")} /> + {(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (

{t("loading")}

)} @@ -105,42 +111,84 @@ export function ApplicationConfigurationsPage({ )} {ready && ( - - - {!filtered.length && ( -

{t("applications.empty")}

- )} - {filtered.map((item) => ( -
-
-

{resource(item.values.resourceId)}

-

{label(item.values.profileId)}

-

- {t(`applications.${item.values.delivery}`)} · {t(`mail.${item.values.state}`)} -

- {item.values.version && ( -

- {t("applications.versionValue", { version: item.values.version })} -

- )} - {item.needsReview && ( -

{t("applications.review")}

- )} -
-
- - {!["removed", "never_configured"].includes(item.values.state) && ( - - )} -
-
- ))} -
-
+ resource(item.values.resourceId), + hideable: false, + render: (item) => ( + + ), + }, + { + id: "profile", + label: t("profiles.title"), + value: (item) => label(item.values.profileId), + render: (item) => <>{label(item.values.profileId)}, + }, + { + id: "state", + label: t("status"), + value: (item) => t(`mail.${item.values.state}`), + render: (item) => ( + + {t(`mail.${item.values.state}`)} + + ), + }, + { + id: "delivery", + label: t("applications.delivery"), + value: (item) => t(`applications.${item.values.delivery}`), + render: (item) => <>{t(`applications.${item.values.delivery}`)}, + }, + { + id: "version", + label: t("applications.version"), + value: (item) => item.values.version, + render: (item) => <>{item.values.version || "—"}, + }, + { + id: "review", + label: t("profiles.review"), + value: (item) => item.needsReview, + render: (item) => <>{item.needsReview ? t("applications.review") : "—"}, + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + {" "} +
+ setView(item.id)}> + {t("mail.history")} + + {!["removed", "never_configured"].includes(item.values.state) && ( + setEditor(item.id)}>{t("edit")} + )} +
{" "} +
+ ), + }, + ]} + emptyMessage={t("applications.empty")} + items={filtered} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("applications.search")} + title={t("applications.title")} + /> )} {editor && ( |")applications\./.test(html), false); } }); test("aplicaciones: selectores con alta rápida y ayuda inferior antes de guardar", async () => { @@ -81,6 +81,6 @@ test("aplicaciones: selectores con alta rápida y ayuda inferior antes de guarda assert.ok(html.includes(text.applications.newResource)); assert.ok(html.indexOf(text.applications.help) > html.indexOf(text.applications.delivery)); assert.ok(html.indexOf(text.save) > html.indexOf(text.applications.help)); - assert.ok(!html.includes("applications.")); + assert.equal(/(?:>|")applications\./.test(html), false); } }); diff --git a/modules/companies/src/web/certificates/certificate-configurations-page.tsx b/modules/companies/src/web/certificates/certificate-configurations-page.tsx index 1e9768f..a507022 100644 --- a/modules/companies/src/web/certificates/certificate-configurations-page.tsx +++ b/modules/companies/src/web/certificates/certificate-configurations-page.tsx @@ -4,9 +4,14 @@ import { inventoryKeys as resourceKeys, } from "@metis/resources/client"; import { Button } from "@repo/shadcn-ui/components/button"; -import { Card, CardContent } from "@repo/shadcn-ui/components/card"; +import { + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/shadcn-ui/components/entity-list"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { Input } from "@repo/shadcn-ui/components/input"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -71,18 +76,19 @@ export function CertificateConfigurationsPage({ const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true; return (
-
-

{t("certificates.title")}

- -
- setSearch(event.target.value)} - placeholder={t("certificates.search")} - value={search} + + {" "} + {" "} + + } + locale={i18n.language} + title={t("certificates.title")} /> + {(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (

{t("loading")}

)} @@ -105,51 +111,91 @@ export function CertificateConfigurationsPage({ )} {ready && ( - - - {!filtered.length && ( -

{t("certificates.empty")}

- )} - {filtered.map((item) => ( -
-
-

{resource(item.values.resourceId)}

-

{label(item.values.profileId)}

-

{t(`mail.${item.values.state}`)}

- {item.values.location && ( -

- {t("certificates.locationValue", { location: item.values.location })} -

- )} - {item.expiresOn && ( -

- {t("certificates.expiryValue", { - date: new Intl.DateTimeFormat(i18n.language, { - dateStyle: "medium", - timeZone: "UTC", - }).format(new Date(item.expiresOn)), - })} - {item.expired ? ` · ${t("certificates.expired")}` : ""} -

- )} - {item.needsReview && ( -

{t("certificates.review")}

- )} -
-
- - {!["removed", "never_configured"].includes(item.values.state) && ( - - )} -
-
- ))} -
-
+ resource(item.values.resourceId), + hideable: false, + render: (item) => ( + + ), + }, + { + id: "profile", + label: t("profiles.title"), + value: (item) => label(item.values.profileId), + render: (item) => <>{label(item.values.profileId)}, + }, + { + id: "state", + label: t("status"), + value: (item) => t(`mail.${item.values.state}`), + render: (item) => ( + + {t(`mail.${item.values.state}`)} + + ), + }, + { + id: "location", + label: t("certificates.location"), + value: (item) => item.values.location, + render: (item) => <>{item.values.location || "—"}, + }, + { + id: "expiry", + label: t("telework.expiresOn"), + value: (item) => item.expiresOn, + render: (item) => ( + <> + {item.expiresOn + ? new Intl.DateTimeFormat(i18n.language).format(new Date(item.expiresOn)) + : "—"} + {item.expired ? ` · ${t("certificates.expired")}` : ""} + + ), + }, + { + id: "review", + label: t("profiles.review"), + value: (item) => item.needsReview, + render: (item) => <>{item.needsReview ? t("certificates.review") : "—"}, + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + {" "} +
+ setView(item.id)}> + {t("mail.history")} + + {!["removed", "never_configured"].includes(item.values.state) && ( + setEditor(item.id)}>{t("edit")} + )} +
{" "} +
+ ), + }, + ]} + emptyMessage={t("certificates.empty")} + items={filtered} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("certificates.search")} + title={t("certificates.title")} + /> )} {editor && ( void; }) { - const { t } = useTranslation("companies"); + const { t, i18n } = useTranslation("companies"); const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list }); const [selectedId, setSelectedId] = useState(); const [createOpen, setCreateOpen] = useState(false); const [search, setSearch] = useState(""); - const id = useId(); + const _id = useId(); const table = useReactTable({ data: query.data?.companies ?? EMPTY_COMPANIES, columns: COMPANY_COLUMNS, @@ -65,23 +61,18 @@ export function CompaniesPage({ const companies = table.getRowModel().rows.map((row) => row.original); return (
-
-
-

{t("title")}

-

{t("help")}

-
- -
-
- - setSearch(event.target.value)} - placeholder={t("searchPlaceholder")} - value={search} - /> -
+ + {" "} + {" "} + + } + description={t("help")} + locale={i18n.language} + title={t("title")} + /> + {query.isPending &&

{t("loading")}

} {query.isError && ( @@ -95,40 +86,52 @@ export function CompaniesPage({ )} {query.data && ( - - - {companies.length === 0 ? ( -

{t(search ? "noResults" : "empty")}

- ) : ( - - - - {t("name")} - {t("status")} - {t("actions")} - - - - {companies.map((company) => ( - - {company.name} - {t(company.active ? "active" : "inactive")} - - - - - ))} - -
- )} -
-
+ company.name, + hideable: false, + render: (company) => ( + + ), + }, + { + id: "state", + label: t("status"), + value: (company) => t(company.active ? "active" : "inactive"), + render: (company) => ( + + {t(company.active ? "active" : "inactive")} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (company) => ( + + selectCompany(company.id)}>{t("open")} + + ), + }, + ]} + emptyMessage={t("empty")} + items={companies} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("searchPlaceholder")} + title={t("title")} + /> )} (); const selected = company.sites.find((site) => site.id === selectedId); return (
- - -
-

{t("sites")}

- -
- {company.sites.length === 0 ? ( -

{t("noSites")}

- ) : ( -
- {company.sites.map((site) => ( -
-
-

{site.name}

-

- {site.address ?? t("noAddress")} -

-
- -
- ))} -
- )} -
-
+ setCreateOpen(true)}>{t("newSite")}} + locale={i18n.language} + title={t("sites")} + /> + site.name, + hideable: false, + render: (site) => {site.name}, + }, + { + id: "address", + label: t("addressOptional"), + value: (site) => site.address, + render: (site) => ( + {site.address ?? t("noAddress")} + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (site) => ( + + setSelectedId(site.id)}>{t("edit")} + + ), + }, + ]} + emptyMessage={t("noSites")} + items={company.sites} + locale={i18n.language} + title={t("sites")} + /> (); const selected = company.departments.find((item) => item.id === editor); + return ( - - -
-

{t("departments")}

- -
- {company.departments.length ? ( - - - - {t("name")} - {t("status")} - {t("people")} - {t("actions")} - - - - {company.departments.map((department) => ( - - {department.name} - {t(department.active ? "active" : "inactive")} - - { - company.assignments.filter( - (item) => item.departmentId === department.id && item.endDate === null - ).length - } - - - - - - ))} - -
- ) : ( -

{t("noDepartments")}

- )} - { - if (!open) { - setEditor(undefined); - } - }} - open={editor !== undefined} - title={t(selected ? "editDepartment" : "newDepartment")} - > - setEditor(undefined)} - /> - -
-
+
+ setEditor("new")}>{t("newDepartment")}} + locale={i18n.language} + title={t("departments")} + /> + item.name, + hideable: false, + render: (item) => <>{item.name}, + }, + { + id: "state", + label: t("status"), + value: (item) => t(item.active ? "active" : "inactive"), + render: (item) => ( + + {t(item.active ? "active" : "inactive")} + + ), + }, + { + id: "people", + label: t("people"), + value: (item) => + company.assignments.filter( + (entry) => entry.departmentId === item.id && entry.endDate === null + ).length, + render: (item) => ( + <> + { + company.assignments.filter( + (entry) => entry.departmentId === item.id && entry.endDate === null + ).length + } + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + setEditor(item.id)}>{t("edit")} + + ), + }, + ]} + emptyMessage={t("noDepartments")} + items={company.departments} + locale={i18n.language} + title={t("departments")} + /> + { + if (!open) { + setEditor(undefined); + } + }} + open={editor !== undefined} + title={t(selected ? "editDepartment" : "newDepartment")} + > + setEditor(undefined)} + /> + +
); } diff --git a/modules/companies/src/web/components/people-section.tsx b/modules/companies/src/web/components/people-section.tsx index 290632d..f8f96af 100644 --- a/modules/companies/src/web/components/people-section.tsx +++ b/modules/companies/src/web/components/people-section.tsx @@ -1,17 +1,15 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { Card, CardContent } from "@repo/shadcn-ui/components/card"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; -import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { Input } from "@repo/shadcn-ui/components/input"; -import { Label } from "@repo/shadcn-ui/components/label"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@repo/shadcn-ui/components/table"; + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/shadcn-ui/components/entity-list"; +import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; +import { Label } from "@repo/shadcn-ui/components/label"; import { useId, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -82,57 +80,73 @@ function PersonDetails({ )} -
-

{t("departmentAssignments")}

- -
- {assignments.length ? ( - - - - {t("department")} - {t("position")} - {t("startDate")} - {t("endDate")} - {t("primary")} - {t("actions")} - - - - {assignments.map((item) => ( - - - {departmentName(item.departmentId)} - {company.departments.find((department) => department.id === item.departmentId) - ?.active === false && ( - - {t("inactiveDepartment")} - - )} - - {item.position ?? "—"} - {item.startDate} - {item.endDate ?? t("currentAssignment")} - {item.isPrimary ? t("yes") : "—"} - -
- - -
-
-
- ))} -
-
- ) : ( -

{t("noAssignments")}

- )} + setEditor("new")}> + {t("addAssignment")} + + } + locale={i18n.language} + title={t("departmentAssignments")} + /> + departmentName(item.departmentId), + hideable: false, + render: (item) => ( + <> + {departmentName(item.departmentId)} + {company.departments.find((entry) => entry.id === item.departmentId)?.active === + false &&

{t("inactiveDepartment")}

} + + ), + }, + { + id: "position", + label: t("position"), + value: (item) => item.position, + render: (item) => <>{item.position ?? "—"}, + }, + { + id: "start", + label: t("startDate"), + value: (item) => item.startDate, + render: (item) => <>{item.startDate}, + }, + { + id: "end", + label: t("endDate"), + value: (item) => item.endDate, + render: (item) => <>{item.endDate ?? t("currentAssignment")}, + }, + { + id: "primary", + label: t("primary"), + value: (item) => item.isPrimary, + render: (item) => <>{item.isPrimary ? t("yes") : "—"}, + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + setEditor(item.id)}>{t("edit")} + setHistoryId(item.id)}> + {t("history")} + + + ), + }, + ]} + emptyMessage={t("noAssignments")} + items={assignments} + locale={i18n.language} + title={t("departmentAssignments")} + /> {!company.departments.some((item) => item.active) && (

{t("createDepartmentFirst")}

)} @@ -194,7 +208,7 @@ function PersonDetails({ ); } export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) { - const { t } = useTranslation("companies"); + const { t, i18n } = useTranslation("companies"); const filterId = useId(); const [selection, setSelection] = useState< { mode: "new" | "detail" | "edit"; id?: string } | undefined @@ -247,81 +261,105 @@ export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csr return (
{personEditor} -
-

{t("people")}

- -
-
-
- - setSearch(event.target.value)} value={search} /> -
-
- setContactsOnly(checked === true)} - /> - -
-
- - - {people.length ? ( - - - - {t("name")} - {t("position")} - {t("departments")} - {t("status")} - {t("actions")} - - - - {people.map((person) => ( - - - {person.name} - {person.isContact && ( - - {t("isContact")} - - )} - - {person.position ?? "—"} - - {company.assignments - .filter((item) => item.personId === person.id && item.endDate === null) - .map( - (item) => - (company.departments.find( - (department) => department.id === item.departmentId - )?.name ?? "—") + (item.isPrimary ? " ★" : "") - ) - .join(", ") || "—"} - - {t(person.active ? "active" : "inactive")} - - - - - ))} - -
- ) : ( -

- {t(search || contactsOnly ? "noResults" : "noPeople")} -

- )} -
-
+ + {" "} + {" "} + + } + locale={i18n.language} + title={t("people")} + /> + + person.name, + hideable: false, + render: (person) => ( + <> + + {person.isContact && ( +

{t("isContact")}

+ )} + + ), + }, + { + id: "position", + label: t("position"), + value: (person) => person.position, + render: (person) => <>{person.position ?? "—"}, + }, + { + id: "departments", + label: t("departments"), + render: (person) => ( + <> + {company.assignments + .filter((item) => item.personId === person.id && item.endDate === null) + .map( + (item) => + (company.departments.find((department) => department.id === item.departmentId) + ?.name ?? "—") + (item.isPrimary ? " ★" : "") + ) + .join(", ") || "—"} + + ), + }, + { + id: "state", + label: t("status"), + value: (person) => t(person.active ? "active" : "inactive"), + render: (person) => ( + + {t(person.active ? "active" : "inactive")} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (person) => ( + + setSelection({ mode: "detail", id: person.id })}> + {t("open")} + + + ), + }, + ]} + emptyMessage={t("noPeople")} + filterKey={String(contactsOnly)} + filters={ +
+ setContactsOnly(checked === true)} + /> + +
+ } + filtersApplied={contactsOnly} + items={people} + locale={i18n.language} + onClearFilters={() => setContactsOnly(false)} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("searchPerson")} + title={t("people")} + />
); } diff --git a/modules/companies/src/web/folders/folder-mappings-page.tsx b/modules/companies/src/web/folders/folder-mappings-page.tsx index f2be944..e801c5f 100644 --- a/modules/companies/src/web/folders/folder-mappings-page.tsx +++ b/modules/companies/src/web/folders/folder-mappings-page.tsx @@ -1,76 +1,263 @@ -import {Button} from "@repo/shadcn-ui/components/button"; -import {Card,CardContent} from "@repo/shadcn-ui/components/card"; -import {FormDialog} from "@repo/shadcn-ui/components/form-dialog"; -import {Input} from "@repo/shadcn-ui/components/input"; -import {useQuery} from "@tanstack/react-query"; -import {useState} from "react"; -import {useTranslation} from "react-i18next"; -import type {CompanyDTO,AccessItemDTO} from "../../common"; -import {accessApi,accessKeys} from "../access/access-api"; -import {profileApi,profileKeys} from "../profiles/work-profile-api"; -import {folderApi,folderKeys} from "./folder-api"; -import {FolderFeedback} from "./folder-feedback"; -import {FolderMappingForm} from "./folder-mapping-form"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/shadcn-ui/components/entity-list"; +import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; -export function FolderMappingsPage({company,csrfToken,initialProfileId}:{ - company:CompanyDTO;csrfToken:string;initialProfileId?:string; -}){ - const {t,i18n}=useTranslation("companies"); - const [editor,setEditor]=useState(),[view,setView]=useState(),[search,setSearch]=useState(""); - const query=useQuery({queryKey:folderKeys.company(company.id),queryFn:()=>folderApi.get(company.id)}); - const access=useQuery({queryKey:accessKeys.scope(company.id),queryFn:()=>accessApi.request(company.id)}); - const profiles=useQuery({queryKey:profileKeys.company(company.id),queryFn:()=>profileApi.get(company.id)}); - const items=access.data?.items ?? [],mappings=query.data?.mappings ?? []; - const profile=(id:string)=>profiles.data?.profiles.find(item=>item.id===id)?.values.name ?? id; - const folder=(id:string)=>folderDetails(items,id); - const filtered=mappings.filter(item=>(!initialProfileId || item.values.profileId===initialProfileId) && - [item.values.path,profile(item.values.profileId),folder(item.values.needId).name].some(text=>text.toLowerCase().includes(search.trim().toLowerCase()))); - const edited=mappings.find(item=>item.id===editor),viewed=mappings.find(item=>item.id===view); - const ready=[query.data,access.data,profiles.data].every(Boolean); - return
-

{t("folders.title")}

-
- setSearch(event.target.value)} /> - {(query.isPending || access.isPending || profiles.isPending) &&

{t("loading")}

} - - {(query.isError || access.isError || profiles.isError) && } - {ready && - {!filtered.length &&

{t(mappings.length ? "noResults":"folders.empty")}

} - {filtered.map(item=>
-
-

{folder(item.values.needId).name} · {profile(item.values.profileId)}

-

{item.values.driveLetter ? item.values.driveLetter+":":"—"} {item.values.path}

-

{t("folders.permissions")}: {folder(item.values.needId).permissions}

-

{t("mail."+item.values.state)}

- {!["removed","never_configured"].includes(item.values.state) &&

- {t(item.permissionsCovered ? "folders.covered":"folders.uncovered")}

} - {item.needsReview &&

{t("folders.review")}

} -
-
- {!["removed","never_configured"].includes(item.values.state) && }
-
)} -
} - {editor && {if(!open){setEditor(undefined);}}}> - setEditor(undefined)} /> - } - {viewed && {if(!open){setView(undefined);}}}> -
    {[...viewed.history].reverse().map(revision=>
  1. -

    {new Intl.DateTimeFormat(i18n.language,{dateStyle:"medium",timeStyle:"short"}).format(new Date(revision.recordedAt))} · {revision.actorName}

    -

    {revision.values.driveLetter ? revision.values.driveLetter+":":"—"} {revision.values.path}

    -

    {t("mail."+revision.values.state)}

    - {revision.values.notes &&

    {revision.values.notes}

    } -
  2. )}
-
} -
; +import type { AccessItemDTO, CompanyDTO } from "../../common"; +import { accessApi, accessKeys } from "../access/access-api"; +import { profileApi, profileKeys } from "../profiles/work-profile-api"; + +import { folderApi, folderKeys } from "./folder-api"; +import { FolderFeedback } from "./folder-feedback"; +import { FolderMappingForm } from "./folder-mapping-form"; + +export function FolderMappingsPage({ + company, + csrfToken, + initialProfileId, +}: { + company: CompanyDTO; + csrfToken: string; + initialProfileId?: string; +}) { + const { t, i18n } = useTranslation("companies"); + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const [search, setSearch] = useState(""); + const query = useQuery({ + queryKey: folderKeys.company(company.id), + queryFn: () => folderApi.get(company.id), + }); + const access = useQuery({ + queryKey: accessKeys.scope(company.id), + queryFn: () => accessApi.request(company.id), + }); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const items = access.data?.items ?? []; + const mappings = query.data?.mappings ?? []; + const profile = (id: string) => + profiles.data?.profiles.find((item) => item.id === id)?.values.name ?? id; + const folder = (id: string) => folderDetails(items, id); + const filtered = mappings.filter( + (item) => + (!initialProfileId || item.values.profileId === initialProfileId) && + [item.values.path, profile(item.values.profileId), folder(item.values.needId).name].some( + (text) => text.toLowerCase().includes(search.trim().toLowerCase()) + ) + ); + const edited = mappings.find((item) => item.id === editor); + const viewed = mappings.find((item) => item.id === view); + const ready = [query.data, access.data, profiles.data].every(Boolean); + return ( +
+ + {" "} + {" "} + + } + locale={i18n.language} + title={t("folders.title")} + /> + + {(query.isPending || access.isPending || profiles.isPending) && ( +

{t("loading")}

+ )} + + {(query.isError || access.isError || profiles.isError) && ( + + )} + {ready && ( + folder(item.values.needId).name, + hideable: false, + render: (item) => ( + + ), + }, + { + id: "profile", + label: t("profiles.title"), + value: (item) => profile(item.values.profileId), + render: (item) => <>{profile(item.values.profileId)}, + }, + { + id: "path", + label: t("folders.path"), + value: (item) => item.values.path, + render: (item) => <>{item.values.path}, + }, + { + id: "drive", + label: t("folders.letter"), + value: (item) => item.values.driveLetter, + render: (item) => ( + <>{item.values.driveLetter ? `${item.values.driveLetter}:` : "—"} + ), + }, + { + id: "permissions", + label: t("folders.permissions"), + value: (item) => folder(item.values.needId).permissions, + render: (item) => <>{folder(item.values.needId).permissions}, + }, + { + id: "state", + label: t("status"), + value: (item) => t(`mail.${item.values.state}`), + render: (item) => ( + + {t(`mail.${item.values.state}`)} + + ), + }, + { + id: "review", + label: t("profiles.review"), + value: (item) => item.needsReview, + render: (item) => ( + <> + {!["removed", "never_configured"].includes(item.values.state) && ( +

{t(item.permissionsCovered ? "folders.covered" : "folders.uncovered")}

+ )} + {item.needsReview &&

{t("folders.review")}

} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + {" "} +
+ setView(item.id)}> + {t("mail.history")} + + {!["removed", "never_configured"].includes(item.values.state) && ( + setEditor(item.id)}>{t("edit")} + )} +
{" "} +
+ ), + }, + ]} + emptyMessage={t("folders.empty")} + items={filtered} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("folders.search")} + title={t("folders.title")} + /> + )} + {editor && ( + { + if (!open) { + setEditor(undefined); + } + }} + open + title={t(edited ? "folders.edit" : "folders.new")} + wide + > + setEditor(undefined)} + /> + + )} + {viewed && ( + { + if (!open) { + setView(undefined); + } + }} + open + title={t("mail.history")} + wide + > +
    + {[...viewed.history].reverse().map((revision) => ( +
  1. +

    + {new Intl.DateTimeFormat(i18n.language, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(revision.recordedAt))}{" "} + · {revision.actorName} +

    +

    + {revision.values.driveLetter ? `${revision.values.driveLetter}:` : "—"}{" "} + {revision.values.path} +

    +

    {t(`mail.${revision.values.state}`)}

    + {revision.values.notes && ( +

    {revision.values.notes}

    + )} +
  2. + ))} +
+
+ )} +
+ ); } -export function folderDetails(items:readonly AccessItemDTO[],id:string){ - const need=items.find(item=>item.id===id)?.values; - if(need?.kind!=="need"){return {name:id,permissions:""};} - const resource=items.find(item=>item.id===need.resourceId)?.values; - return {name:resource?.kind==="resource" ? resource.name:need.resourceId, - permissions:need.permissionIds.map(permission=>{const item=items.find(item=>item.id===permission)?.values; - return item?.kind==="permission" ? item.name:permission;}).join(" · ")}; +export function folderDetails(items: readonly AccessItemDTO[], id: string) { + const need = items.find((item) => item.id === id)?.values; + if (need?.kind !== "need") { + return { name: id, permissions: "" }; + } + const resource = items.find((item) => item.id === need.resourceId)?.values; + return { + name: resource?.kind === "resource" ? resource.name : need.resourceId, + permissions: need.permissionIds + .map((permission) => { + const item = items.find((item) => item.id === permission)?.values; + return item?.kind === "permission" ? item.name : permission; + }) + .join(" · "), + }; } diff --git a/modules/companies/src/web/intervention-templates/templates-page.tsx b/modules/companies/src/web/intervention-templates/templates-page.tsx index 4b96d6a..7e63ede 100644 --- a/modules/companies/src/web/intervention-templates/templates-page.tsx +++ b/modules/companies/src/web/intervention-templates/templates-page.tsx @@ -1,7 +1,13 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; +import { + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/shadcn-ui/components/entity-list"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { useQuery } from "@tanstack/react-query"; import { useId, useState } from "react"; @@ -18,7 +24,7 @@ export function InterventionTemplatesPage({ scope?: string; csrfToken: string; }) { - const { t } = useTranslation("companies"); + const { t, i18n } = useTranslation("companies"); const prefix = useId(); const query = useQuery({ queryKey: interventionTemplateKeys.list(scope), @@ -37,40 +43,24 @@ export function InterventionTemplatesPage({ ); return (
-
-

{t("interventionTemplates.title")}

- -
-

- {t( + + {" "} + {" "} + + } + description={t( scope === "global" ? "interventionTemplates.scopeGlobal" : "interventionTemplates.scopeCompany" )} -

-
-
- - setSearch(event.target.value)} - placeholder={t("interventionTemplates.filter")} - value={search} - /> -
-
- setShowInactive(value === true)} - /> - -
-
+ locale={i18n.language} + title={t("interventionTemplates.title")} + /> + {query.isPending &&

{t("loading")}

} {query.isError && ( @@ -84,46 +74,106 @@ export function InterventionTemplatesPage({ )} {query.data && !query.isError && ( - <> - {!visible.length &&

{t("interventionTemplates.empty")}

} - {visible.map((template) => ( -
-
-
-

{template.values.name}

-

- {t( - template.companyId - ? "interventionTemplates.company" - : "interventionTemplates.global" - )}{" "} - · {t(template.values.active ? "active" : "inactive")} -

-
-
- {template.companyId === (scope === "global" ? null : scope) && ( - + template.values.name, + hideable: false, + render: (template) => <>{template.values.name}, + }, + { + id: "scope", + label: t("interventionTemplates.global"), + value: (template) => + t( + template.companyId + ? "interventionTemplates.company" + : "interventionTemplates.global" + ), + render: (template) => ( + <> + {t( + template.companyId + ? "interventionTemplates.company" + : "interventionTemplates.global" )} - -
-
- - -
- ))} - + + ), + }, + { + id: "state", + label: t("status"), + value: (template) => t(template.values.active ? "active" : "inactive"), + render: (template) => ( + + {t(template.values.active ? "active" : "inactive")} + + ), + }, + { + id: "steps", + label: t("interventions.steps"), + value: (template) => template.values.steps.length, + render: (template) => ( +
+ + {t("interventions.steps")} ({template.values.steps.length}) + + +
+ ), + }, + { + id: "history", + label: t("history"), + render: (template) => , + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (template) => ( + + {" "} +
+ {template.companyId === (scope === "global" ? null : scope) && ( + setEditor({ id: template.id })}> + {t("edit")} + + )} + setEditor({ copyId: template.id })}> + {t("interventionTemplates.copy")} + +
{" "} +
+ ), + }, + ]} + emptyMessage={t("interventionTemplates.empty")} + filterKey={String(showInactive)} + filters={ +
+ setShowInactive(value === true)} + /> + +
+ } + filtersApplied={!showInactive} + items={visible} + locale={i18n.language} + onClearFilters={() => setShowInactive(true)} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("interventionTemplates.filter")} + title={t("interventionTemplates.title")} + /> )} { +test("plantillas de intervención: listado sin formulario, es/en y catálogo global junto al propio de compañía", async () => { const global = await render(); assert.ok(global.includes("Plantillas de intervención")); assert.equal(global.includes("); - assert.equal((scoped.match(/>Editar, "en"); assert.ok(english.includes("Intervention templates")); }); diff --git a/modules/companies/src/web/interventions/interventions-page.tsx b/modules/companies/src/web/interventions/interventions-page.tsx index 9f46b77..06ae6b3 100644 --- a/modules/companies/src/web/interventions/interventions-page.tsx +++ b/modules/companies/src/web/interventions/interventions-page.tsx @@ -1,6 +1,12 @@ import { Button } from "@repo/shadcn-ui/components/button"; +import { + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/shadcn-ui/components/entity-list"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { Input } from "@repo/shadcn-ui/components/input"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; @@ -76,90 +82,25 @@ export function InterventionsPage({ : ""; return (
-
-

- {t(companyId ? "interventions.title" : "interventions.global")} -

- -
-
- setFilters({ ...filters, search: event.target.value })} - placeholder={t("interventions.search")} - value={filters.search} - /> - {!companyId && ( - setFilters({ ...filters, company })} - options={ - query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? [] - } - placeholder={t("interventions.allCompanies")} - value={filters.company} - /> - )} - setFilters({ ...filters, state })} - options={InterventionStateSchema.options.map((value) => ({ - value, - label: t(`interventions.states.${value}`), - }))} - placeholder={t("interventions.allStates")} - value={filters.state} - /> - setFilters({ ...filters, assignee })} - options={[ - { value: "unassigned", label: t("interventions.unassigned") }, - ...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ?? - []), - ]} - placeholder={t("interventions.allAssignees")} - value={filters.assignee} - /> - setFilters({ ...filters, dateFrom: event.target.value })} - type="date" - value={filters.dateFrom} - /> - setFilters({ ...filters, dateTo: event.target.value })} - type="date" - value={filters.dateTo} - /> - -
+ + {" "} + {" "} + + } + locale={i18n.language} + title={t(companyId ? "interventions.title" : "interventions.global")} + /> + {query.isPending &&

{t("interventions.loading")}

} {query.isError && (
@@ -174,60 +115,190 @@ export function InterventionsPage({
)} - {query.data && !items.length && ( -

{t("interventions.empty")}

- )} -
    - {items.map((intervention) => ( -
  • -
    -
    -

    - {intervention.reference} · {intervention.companyName} -

    -

    {intervention.values.subject}

    -
    -
    - - -
    -
    -

    - {t(`interventions.states.${intervention.values.state}`)} ·{" "} - {intervention.assigneeName ?? t("interventions.unassigned")} ·{" "} - {intervention.values.minutes === null - ? t("interventions.unknownTime") - : `${intervention.values.minutes} min`} -

    -

    - {intervention.ticketReference ?? t("interventions.internal")} - {intervention.values.plannedDate ? ` · ${intervention.values.plannedDate}` : ""} -

    - {intervention.values.steps.length > 0 && ( -

    - {t("interventions.progress", { - done: intervention.values.steps.filter((step) => step.state === "done").length, - total: intervention.values.steps.length, - })} -

    + + intervention.reference, + hideable: false, + render: (intervention) => ( + + ), + }, + { + id: "subject", + label: t("interventions.subject"), + value: (intervention) => intervention.values.subject, + hideable: false, + render: (intervention) => ( + + ), + }, + { + id: "company", + label: t("tickets.company"), + value: (intervention) => intervention.companyName, + render: (intervention) => <>{intervention.companyName}, + }, + { + id: "state", + label: t("interventions.state"), + value: (intervention) => t(`interventions.states.${intervention.values.state}`), + render: (intervention) => ( + + {t(`interventions.states.${intervention.values.state}`)} + + ), + }, + { + id: "assignee", + label: t("interventions.assignee"), + value: (intervention) => intervention.assigneeName, + render: (intervention) => ( + <>{intervention.assigneeName ?? t("interventions.unassigned")} + ), + }, + { + id: "minutes", + label: t("interventions.minutes"), + value: (intervention) => intervention.values.minutes, + render: (intervention) => ( + <> + {intervention.values.minutes === null + ? t("interventions.unknownTime") + : `${intervention.values.minutes} min`} + + ), + }, + { + id: "ticket", + label: t("interventions.ticket"), + value: (intervention) => intervention.ticketReference, + render: (intervention) => ( + <>{intervention.ticketReference ?? t("interventions.internal")} + ), + }, + { + id: "planned", + label: t("interventions.plannedDate"), + value: (intervention) => intervention.values.plannedDate, + render: (intervention) => <>{intervention.values.plannedDate ?? "—"}, + }, + { + id: "progress", + label: t("interventions.steps"), + render: (intervention) => ( + <> + {intervention.values.steps.length > 0 + ? t("interventions.progress", { + done: intervention.values.steps.filter((step) => step.state === "done") + .length, + total: intervention.values.steps.length, + }) + : "—"} + + ), + }, + { + id: "updated", + label: t("tickets.updated"), + value: (intervention) => intervention.history.at(-1)?.recordedAt, + render: (intervention) => <>{date(intervention.history.at(-1)?.recordedAt)}, + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (intervention) => ( + + {" "} +
    + setView(intervention)}> + {t("interventions.details")} + + { + setEdit(intervention); + setOpen(true); + }} + > + {t("edit")} + +
    {" "} +
    + ), + }, + ]} + emptyMessage={t("interventions.empty")} + entityLabel={t("interventions.title")} + filterKey={JSON.stringify(filters)} + filters={ + <> + {" "} + {!companyId && ( + setFilters({ ...filters, company })} + options={ + query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? [] + } + placeholder={t("interventions.allCompanies")} + value={filters.company} + /> )} -

    - {t("tickets.updated")}: {date(intervention.history.at(-1)?.recordedAt)} -

    -
  • - ))} -
+ setFilters({ ...filters, state })} + options={InterventionStateSchema.options.map((value) => ({ + value, + label: t(`interventions.states.${value}`), + }))} + placeholder={t("interventions.allStates")} + value={filters.state} + /> + setFilters({ ...filters, assignee })} + options={[ + { value: "unassigned", label: t("interventions.unassigned") }, + ...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ?? + []), + ]} + placeholder={t("interventions.allAssignees")} + value={filters.assignee} + />{" "} + + } + filtersApplied={Object.values(filters).some(Boolean)} + items={items} + loading={query.isPending} + locale={i18n.language} + onClearFilters={() => + setFilters({ search: "", company: "", state: "", assignee: "", dateFrom: "", dateTo: "" }) + } + onSearchChange={(search) => setFilters({ ...filters, search })} + search={filters.search} + searchPlaceholder={t("interventions.search")} + title={t(companyId ? "interventions.title" : "interventions.global")} + unavailable={query.isError} + /> -
-

{t("mail.title")}

- -
- setSearch(event.target.value)} - placeholder={t("mail.search")} - value={search} + + {" "} + {" "} + + } + locale={i18n.language} + title={t("mail.title")} /> + {(query.isPending || inventory.isPending || profiles.isPending || mailboxes.isPending) && (

{t("loading")}

)} @@ -112,40 +118,84 @@ export function MailConfigurationsPage({ )} {query.data && ( - - - {!filtered.length && ( -

- {t(configurations.length ? "noResults" : "mail.empty")} -

- )} - {filtered.map((item) => ( -
-
-

- {address(item)} · {t(`mail.${item.values.method}`)} -

-

{target(item.values)}

-

{t(`mail.${item.values.state}`)}

- {item.legacy &&

{t("mail.legacy")}

} - {item.needsReview && ( -

{t("mail.review")}

- )} -
-
- - {!["removed", "never_configured"].includes(item.values.state) && ( - - )} -
-
- ))} -
-
+ address(item), + hideable: false, + render: (item) => ( + + ), + }, + { + id: "profile", + label: t("profiles.title"), + value: (item) => target(item.values), + render: (item) => <>{target(item.values)}, + }, + { + id: "method", + label: t("mail.method"), + value: (item) => item.values.method, + render: (item) => <>{item.values.method}, + }, + { + id: "state", + label: t("status"), + value: (item) => t(`mail.${item.values.state}`), + render: (item) => ( + + {t(`mail.${item.values.state}`)} + + ), + }, + { + id: "review", + label: t("profiles.review"), + value: (item) => item.needsReview, + render: (item) => ( + <> + {item.legacy &&

{t("mail.legacy")}

} + {item.needsReview &&

{t("mail.review")}

} + {!(item.legacy || item.needsReview) && "—"} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + {" "} +
+ setView(item.id)}> + {t("mail.history")} + + {!["removed", "never_configured"].includes(item.values.state) && ( + setEditor(item.id)}>{t("edit")} + )} +
{" "} +
+ ), + }, + ]} + emptyMessage={t("mail.empty")} + items={filtered} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("mail.search")} + title={t("mail.title")} + /> )} {editor && ( (); const [view, setView] = useState(); const [search, setSearch] = useState(""); @@ -61,18 +66,19 @@ export function PrinterConfigurationsPage({ const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true; return (
-
-

{t("printers.title")}

- -
- setSearch(event.target.value)} - placeholder={t("printers.search")} - value={search} + + {" "} + {" "} + + } + locale={i18n.language} + title={t("printers.title")} /> + {(query.isPending || profiles.isPending || inventory.isPending) && (

{t("loading")}

)} @@ -90,40 +96,84 @@ export function PrinterConfigurationsPage({ )} {ready && ( - - - {!filtered.length && ( -

{t("printers.empty")}

- )} - {filtered.map((item) => ( -
-
-

{asset(item.values.printerId)}

-

{label(item.values.profileId)}

-

- {t(`printers.${item.values.connection}`)} · {t(`mail.${item.values.state}`)} -

- {item.values.isDefault && ( -

{t("printers.default")}

- )} - {item.needsReview && ( -

{t("printers.review")}

- )} -
-
- - {!["removed", "never_configured"].includes(item.values.state) && ( - - )} -
-
- ))} -
-
+ asset(item.values.printerId), + hideable: false, + render: (item) => ( + + ), + }, + { + id: "profile", + label: t("profiles.title"), + value: (item) => label(item.values.profileId), + render: (item) => <>{label(item.values.profileId)}, + }, + { + id: "state", + label: t("status"), + value: (item) => t(`mail.${item.values.state}`), + render: (item) => ( + + {t(`mail.${item.values.state}`)} + + ), + }, + { + id: "connection", + label: t("printers.connection"), + value: (item) => t(`printers.${item.values.connection}`), + render: (item) => <>{t(`printers.${item.values.connection}`)}, + }, + { + id: "default", + label: t("printers.default"), + value: (item) => item.values.isDefault, + render: (item) => <>{item.values.isDefault ? t("printers.default") : "—"}, + }, + { + id: "review", + label: t("profiles.review"), + value: (item) => item.needsReview, + render: (item) => <>{item.needsReview ? t("printers.review") : "—"}, + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + {" "} +
+ setView(item.id)}> + {t("mail.history")} + + {!["removed", "never_configured"].includes(item.values.state) && ( + setEditor(item.id)}>{t("edit")} + )} +
{" "} +
+ ), + }, + ]} + emptyMessage={t("printers.empty")} + items={filtered} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("printers.search")} + title={t("printers.title")} + /> )} {editor && ( |")printers\./.test(html), false); } }); test("impresoras: selectores con alta rápida y ayuda inferior antes de guardar", async () => { @@ -78,6 +78,6 @@ test("impresoras: selectores con alta rápida y ayuda inferior antes de guardar" assert.ok(html.includes(text.printers.newAsset)); assert.ok(html.indexOf(text.printers.help) > html.indexOf(text.printers.connection)); assert.ok(html.indexOf(text.save) > html.indexOf(text.printers.help)); - assert.ok(!html.includes("printers.")); + assert.equal(/(?:>|")printers\./.test(html), false); } }); diff --git a/modules/companies/src/web/processes/process-templates-page.tsx b/modules/companies/src/web/processes/process-templates-page.tsx index 230336e..15ecca8 100644 --- a/modules/companies/src/web/processes/process-templates-page.tsx +++ b/modules/companies/src/web/processes/process-templates-page.tsx @@ -1,5 +1,11 @@ import { Button } from "@repo/shadcn-ui/components/button"; -import { Card, CardContent } from "@repo/shadcn-ui/components/card"; +import { + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/shadcn-ui/components/entity-list"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; @@ -9,7 +15,7 @@ import { processApi, processKeys } from "./process-api"; import { ProcessFeedback } from "./process-feedback"; import { ProcessTemplateForm } from "./process-template-form"; export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) { - const { t } = useTranslation("companies"); + const { t, i18n } = useTranslation("companies"); const query = useQuery({ queryKey: processKeys.catalog(), queryFn: processApi.catalog }); const [editor, setEditor] = useState(); const [copy, setCopy] = useState(); @@ -22,13 +28,20 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) { }; return (
-
-

{t("processes.templates")}

- -
-

{t("processes.globalTemplates")}

+ + {" "} + {" "} + + } + description={t("processes.globalTemplates")} + locale={i18n.language} + title={t("processes.templates")} + /> + {query.isPending &&

{t("loading")}

} {query.isError && ( @@ -42,44 +55,56 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) { )} {query.data && !query.isError && ( - - - {templates.map((template) => ( -
-
-
-

{template.values.name}

-

- {t(`processes.${template.values.kind}`)} ·{" "} - {t(template.values.active ? "active" : "inactive")} -

-
-
- - -
-
-
    - {template.values.instructions.map((instruction) => ( -
  1. {instruction}
  2. - ))} -
+ template.values.name, + hideable: false, + render: (template) => <>{template.values.name}, + }, + { + id: "kind", + label: t("processes.kind"), + value: (template) => t(`processes.${template.values.kind}`), + render: (template) => <>{t(`processes.${template.values.kind}`)}, + }, + { + id: "state", + label: t("status"), + value: (template) => t(template.values.active ? "active" : "inactive"), + render: (template) => ( + + {t(template.values.active ? "active" : "inactive")} + + ), + }, + { + id: "steps", + label: t("processes.instructions"), + render: (template) => (
- {t("processes.history")} -
    + + {t("processes.instructions")} ({template.values.instructions.length}) + +
      + {template.values.instructions.map((instruction) => ( +
    1. {instruction}
    2. + ))} +
    +
+ ), + }, + { + id: "history", + label: t("processes.history"), + render: (template) => ( +
+ {t("processes.history")} +
    {[...template.history].reverse().map((r) => ( -
  1. +
  2. {r.recordedAt} · {r.actorName}

    {r.values.name} · {t(r.values.active ? "active" : "inactive")} @@ -94,10 +119,34 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) { ))}

-
- ))} -
-
+ ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (template) => ( + + {" "} +
+ setEditor(template.id)}>{t("edit")} + { + setCopy(template.id); + setEditor("new"); + }} + > + {t("processes.copy")} + +
{" "} +
+ ), + }, + ]} + items={templates} + locale={i18n.language} + title={t("processes.templates")} + /> )} (); @@ -72,62 +77,112 @@ export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csr ) : ( !selected && ( <> -
-

{t("processes.title")}

-
- - -
-
- setSearch(e.target.value)} - placeholder={t("processes.search")} - value={search} + + {" "} +
+ + +
{" "} + + } + locale={i18n.language} + title={t("processes.title")} /> - + {query.data && !query.isError && ( - - - {!filtered.length && ( -

- {t(processes.length ? "noResults" : "processes.empty")} -

- )} - {filtered.map((p) => ( -
-
-

{p.values.name}

-

- {person(p.values.personId)} · {t(`processes.${p.values.kind}`)} ·{" "} - {t(`processes.${p.values.state}`)} -

-

- {t("processes.progress", { - resolved: p.values.tasks.filter((task) => task.status !== "pending") - .length, - total: p.values.tasks.length, - })} -

-
- -
- ))} -
-
+ p.values.name, + hideable: false, + render: (p) => ( + + ), + }, + { + id: "person", + label: t("people"), + value: (p) => person(p.values.personId), + render: (p) => <>{person(p.values.personId)}, + }, + { + id: "kind", + label: t("processes.kind"), + value: (p) => t(`processes.${p.values.kind}`), + render: (p) => <>{t(`processes.${p.values.kind}`)}, + }, + { + id: "state", + label: t("status"), + value: (p) => t(`processes.${p.values.state}`), + render: (p) => ( + + {t(`processes.${p.values.state}`)} + + ), + }, + { + id: "progress", + label: t("processes.instructions"), + render: (p) => ( + <> + {t("processes.progress", { + resolved: p.values.tasks.filter((task) => task.status !== "pending") + .length, + total: p.values.tasks.length, + })} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (p) => ( + + {" "} + setSelected(p.id)}> + {t("processes.openDetail")} + {" "} + + ), + }, + ]} + emptyMessage={t("processes.empty")} + filterKey={String(closed)} + filters={ + + } + filtersApplied={closed} + items={filtered} + locale={i18n.language} + onClearFilters={() => setClosed(false)} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("processes.search")} + title={t("processes.title")} + /> )} ) diff --git a/modules/companies/src/web/profiles/work-profiles-page.tsx b/modules/companies/src/web/profiles/work-profiles-page.tsx index 9e68b73..747c116 100644 --- a/modules/companies/src/web/profiles/work-profiles-page.tsx +++ b/modules/companies/src/web/profiles/work-profiles-page.tsx @@ -1,8 +1,13 @@ import { inventoryApi, inventoryKeys } from "@metis/assets/client"; import { Button } from "@repo/shadcn-ui/components/button"; -import { Card, CardContent } from "@repo/shadcn-ui/components/card"; +import { + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/shadcn-ui/components/entity-list"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { Input } from "@repo/shadcn-ui/components/input"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -95,26 +100,19 @@ export function WorkProfilesPage({ } return (
-
-

{t("profiles.title")}

- -
- setSearch(event.target.value)} - placeholder={t("profiles.search")} - value={search} + + {" "} + {" "} + + } + locale={i18n.language} + title={t("profiles.title")} /> - + {(query.isPending || inventory.isPending || access.isPending) && (

{t("loading")}

)} @@ -132,81 +130,125 @@ export function WorkProfilesPage({ )} {ready && ( - - - {!filtered.length && ( -

- {t(profiles.length ? "noResults" : "profiles.empty")} -

- )} - {filtered.map((item) => ( -
-
-

- {item.values.name} ·{" "} - {t(item.values.active ? "profiles.active" : "profiles.inactive")} -

-

- {person(item.values.personId)} · {asset(item.values.assetId)} -

-

{account(item.values.accountId)}

- {item.needsReview && ( -

{t("profiles.review")}

- )} -
-
- - - - - - - - -
-
- ))} -
-
+ item.values.name, + hideable: false, + render: (item) => ( + + ), + }, + { + id: "person", + label: t("people"), + value: (item) => person(item.values.personId), + render: (item) => <>{person(item.values.personId)}, + }, + { + id: "asset", + label: t("profiles.asset"), + value: (item) => asset(item.values.assetId), + render: (item) => <>{asset(item.values.assetId)}, + }, + { + id: "account", + label: t("profiles.account"), + value: (item) => account(item.values.accountId), + render: (item) => <>{account(item.values.accountId)}, + }, + { + id: "state", + label: t("status"), + value: (item) => t(item.values.active ? "profiles.active" : "profiles.inactive"), + render: (item) => ( + + {t(item.values.active ? "profiles.active" : "profiles.inactive")} + + ), + }, + { + id: "review", + label: t("profiles.review"), + value: (item) => item.needsReview, + render: (item) => <>{item.needsReview ? t("profiles.review") : "—"}, + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + {" "} +
+ setConfiguration({ kind: "environment", profileId: item.id })} + > + {t("environment.title")} + + setConfiguration({ kind: "certificates", profileId: item.id })} + > + {t("certificates.title")} + + setConfiguration({ kind: "applications", profileId: item.id })} + > + {t("applications.title")} + + setConfiguration({ kind: "printers", profileId: item.id })} + > + {t("printers.title")} + + setConfiguration({ kind: "folders", profileId: item.id })} + > + {t("folders.title")} + + setConfiguration({ kind: "mail", profileId: item.id })} + > + {t("mail.title")} + + setView(item.id)}> + {t("profiles.history")} + + setEditor(item.id)}>{t("edit")} +
{" "} +
+ ), + }, + ]} + emptyMessage={t("profiles.empty")} + filterKey={String(archived)} + filters={ + + } + filtersApplied={archived} + items={filtered} + locale={i18n.language} + onClearFilters={() => setArchived(false)} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("profiles.search")} + title={t("profiles.title")} + /> )} {editor && ( -
-

{t("remote.title")}

- -
- setSearch(event.target.value)} - placeholder={t("remote.search")} - value={search} + + {" "} + {" "} + + } + locale={i18n.language} + title={t("remote.title")} /> - + {[query, assets, resources].some((q) => q.isPending) &&

{t("loading")}

} {error && ( @@ -100,41 +98,92 @@ export function RemoteConnectionsPage({ )} {ready && ( - - - {!filtered.length && ( -

- {t(connections.length ? "noResults" : "remote.empty")} -

- )} - {filtered.map((c) => ( -
-
-

{person(c.values.personId)}

-
- - -
-
+ person(c.values.personId), + hideable: false, + render: (c) => ( + + ), + }, + { + id: "method", + label: t("remote.method"), + value: (c) => c.values.method, + render: (c) => <>{c.values.method}, + }, + { + id: "details", + label: t("remote.title"), + render: (c) => ( -
- ))} -
-
+ ), + }, + { + id: "state", + label: t("status"), + value: (c) => t(`mail.${c.values.state}`), + render: (c) => ( + + {t(`mail.${c.values.state}`)} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (c) => ( + + {" "} +
+ setView(c.id)}>{t("remote.history")} + setEditor(c.id)} + > + {t("edit")} + +
{" "} +
+ ), + }, + ]} + emptyMessage={t("remote.empty")} + filterKey={String(closed)} + filters={ + + } + filtersApplied={closed} + items={filtered} + locale={i18n.language} + onClearFilters={() => setClosed(false)} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("remote.search")} + title={t("remote.title")} + /> )} {editor && ( -
-

{t("telework.title")}

- -
- setSearch(event.target.value)} - placeholder={t("telework.search")} - value={search} + + {" "} + {" "} + + } + locale={i18n.language} + title={t("telework.title")} /> + {query.isPending &&

{t("loading")}

} {query.isError && ( @@ -76,31 +82,74 @@ export function TeleworkAuthorizationsPage({ )} {query.data && !query.isError && ( - - - {!filtered.length && ( -

- {t(authorizations.length && !initialPersonId ? "noResults" : "telework.empty")} -

- )} - {filtered.map((item) => ( -
-
-

{person(item.values.personId)}

-
- + ), + }, + { + id: "state", + label: t("status"), + value: (item) => t(`telework.${item.status}`), + render: (item) => ( + {t(`telework.${item.status}`)} + ), + }, + { + id: "start", + label: t("telework.startsOn"), + value: (item) => item.values.startsOn, + render: (item) => <>{item.values.startsOn ?? t("telework.noDate")}, + }, + { + id: "end", + label: t("telework.expiresOn"), + value: (item) => item.values.expiresOn, + render: (item) => <>{item.values.expiresOn ?? t("telework.noDate")}, + }, + { + id: "details", + label: t("telework.title"), + render: (item) => , + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + {" "} +
+ setView(item.id)}> {t("telework.history")} - - -
-
- -
- ))} -
-
+ + setEditor(item.id)}>{t("edit")} +
{" "} + + ), + }, + ]} + emptyMessage={t("telework.empty")} + entityLabel={t("telework.authorizations")} + items={filtered} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("telework.search")} + title={t("telework.title")} + /> )} {editor && ( -
-

- {t(companyId ? "tickets.title" : "tickets.global")} -

- -
-
- setFilters({ ...filters, search: event.target.value })} - placeholder={t("tickets.search")} - value={filters.search} - /> - {!companyId && ( - setFilters({ ...filters, company })} - options={ - query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? [] - } - placeholder={t("tickets.allCompanies")} - value={filters.company} - /> - )} - setFilters({ ...filters, state })} - options={TicketStateSchema.options.map((value) => ({ - value, - label: t(`tickets.states.${value}`), - }))} - placeholder={t("tickets.allStates")} - value={filters.state} - /> - setFilters({ ...filters, priority })} - options={TicketPrioritySchema.options.map((value) => ({ - value, - label: t(`tickets.priorities.${value}`), - }))} - placeholder={t("tickets.allPriorities")} - value={filters.priority} - /> - setFilters({ ...filters, assignee })} - options={[ - { value: "unassigned", label: t("tickets.unassigned") }, - ...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ?? - []), - ]} - placeholder={t("tickets.allAssignees")} - value={filters.assignee} - /> -
+ + {" "} + {" "} + + } + locale={i18n.language} + title={t(companyId ? "tickets.title" : "tickets.global")} + /> + {query.isPending &&

{t("tickets.loading")}

} {query.isError && (
@@ -149,48 +106,173 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
)} - {query.data && !items.length &&

{t("tickets.empty")}

} -
    - {items.map((ticket) => ( -
  • -
    -
    -

    - {ticket.reference} · {ticket.companyName} -

    -

    {ticket.values.subject}

    -
    -
    - - - -
    -
    -

    - {t(`tickets.states.${ticket.values.state}`)} ·{" "} - {t(`tickets.priorities.${ticket.values.priority}`)} ·{" "} - {ticket.requesterName ?? t("tickets.unassigned")} ·{" "} - {ticket.assigneeName ?? t("tickets.unassigned")} -

    -

    - {t("tickets.updated")}: {date(ticket.history.at(-1)?.recordedAt)} -

    -
  • - ))} -
+ + ticket.reference, + hideable: false, + render: (ticket) => ( + + ), + }, + { + id: "subject", + label: t("tickets.subject"), + value: (ticket) => ticket.values.subject, + hideable: false, + render: (ticket) => ( + + ), + }, + { + id: "company", + label: t("tickets.company"), + value: (ticket) => ticket.companyName, + render: (ticket) => <>{ticket.companyName}, + }, + { + id: "state", + label: t("tickets.state"), + value: (ticket) => t(`tickets.states.${ticket.values.state}`), + render: (ticket) => ( + + {t(`tickets.states.${ticket.values.state}`)} + + ), + }, + { + id: "priority", + label: t("tickets.priority"), + value: (ticket) => t(`tickets.priorities.${ticket.values.priority}`), + render: (ticket) => <>{t(`tickets.priorities.${ticket.values.priority}`)}, + }, + { + id: "requester", + label: t("tickets.requester"), + value: (ticket) => ticket.requesterName, + render: (ticket) => <>{ticket.requesterName ?? t("tickets.unassigned")}, + }, + { + id: "assignee", + label: t("tickets.assignee"), + value: (ticket) => ticket.assigneeName, + render: (ticket) => <>{ticket.assigneeName ?? t("tickets.unassigned")}, + }, + { + id: "updated", + label: t("tickets.updated"), + value: (ticket) => ticket.history.at(-1)?.recordedAt, + render: (ticket) => <>{date(ticket.history.at(-1)?.recordedAt)}, + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (ticket) => ( + + {" "} +
+ setInterventionTicket(ticket)}> + {t("interventions.fromTicket")} + + setView(ticket)}> + {t("tickets.details")} + + { + setEdit(ticket); + setOpen(true); + }} + > + {t("edit")} + +
{" "} +
+ ), + }, + ]} + emptyMessage={t("tickets.empty")} + entityLabel={t("tickets.title")} + filterKey={JSON.stringify(filters)} + filters={ + <> + {" "} + {!companyId && ( + setFilters({ ...filters, company })} + options={ + query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? [] + } + placeholder={t("tickets.allCompanies")} + value={filters.company} + /> + )} + setFilters({ ...filters, state })} + options={TicketStateSchema.options.map((value) => ({ + value, + label: t(`tickets.states.${value}`), + }))} + placeholder={t("tickets.allStates")} + value={filters.state} + /> + setFilters({ ...filters, priority })} + options={TicketPrioritySchema.options.map((value) => ({ + value, + label: t(`tickets.priorities.${value}`), + }))} + placeholder={t("tickets.allPriorities")} + value={filters.priority} + /> + setFilters({ ...filters, assignee })} + options={[ + { value: "unassigned", label: t("tickets.unassigned") }, + ...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ?? + []), + ]} + placeholder={t("tickets.allAssignees")} + value={filters.assignee} + />{" "} + + } + filtersApplied={Object.values(filters).some(Boolean)} + items={items} + loading={query.isPending} + locale={i18n.language} + onClearFilters={() => + setFilters({ search: "", company: "", state: "", priority: "", assignee: "" }) + } + onSearchChange={(search) => setFilters({ ...filters, search })} + search={filters.search} + searchPlaceholder={t("tickets.search")} + title={t(companyId ? "tickets.title" : "tickets.global")} + unavailable={query.isError} + /> user.id === selection?.id); return (
-
-
-

{t("users")}

-

{t("usersHelp")}

-
- -
+ + {" "} + {" "} + + } + description={t("usersHelp")} + locale={i18n.language} + title={t("users")} + /> {users.isPending &&

{t("loading")}

} {users.isError && ( @@ -50,57 +52,65 @@ export function UserManagement({ csrfToken }: { csrfToken: string }) { )} {users.data && ( - - - - - - {t("name")} - {t("email")} - {t("status")} - {t("actions")} - - - - {users.data.users.map((user) => ( - - - {user.name} - {user.isInitialAdmin && ( - - {t("initialAdmin")} - - )} - - {user.email} - {t(user.active ? "active" : "inactive")} - -
- - {!user.isInitialAdmin && ( - - )} -
-
-
- ))} -
-
- {users.data.users.length === 0 &&

{t("empty")}

} -
-
+ user.name, + hideable: false, + render: (user) => ( + <> + {user.name} + {user.isInitialAdmin && ( +

{t("initialAdmin")}

+ )} + + ), + }, + { + id: "email", + label: t("email"), + value: (user) => user.email, + render: (user) => <>{user.email}, + }, + { + id: "state", + label: t("status"), + value: (user) => t(user.active ? "active" : "inactive"), + render: (user) => ( + + {t(user.active ? "active" : "inactive")} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (user) => ( + +
+ setSelection({ id: user.id, action: "edit" })}> + {t("edit")} + + {!user.isInitialAdmin && ( + setSelection({ id: user.id, action: "password" })} + > + {t("resetPassword")} + + )} +
+
+ ), + }, + ]} + emptyMessage={t("empty")} + items={users.data.users} + locale={i18n.language} + title={t("users")} + /> )} ("template"); const [search, setSearch] = useState(""); @@ -55,19 +51,18 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT ); return (
-
-
-

- {t(scope === "global" ? "title" : "companyTitle")} -

-

- {t(scope === "global" ? "introduction" : "companyIntroduction")} -

-
- -
+ + + + } + description={t(scope === "global" ? "introduction" : "companyIntroduction")} + locale={i18n.language} + title={t(scope === "global" ? "title" : "companyTitle")} + />
); } + +function CatalogName({ + item, + items, + onView, +}: { + item: CatalogItemDTO; + items: readonly CatalogItemDTO[]; + onView: () => void; +}) { + const { t } = useTranslation("resources"); + const values = item.values; + const inactiveFields = + values.kind === "template" && + values.fields.some( + (field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive + ); + return ( + <> + + {values.description &&

{values.description}

} + {inactiveFields && ( +

{t("disabledFieldsNotice")}

+ )} + + ); +} diff --git a/modules/resources/src/web/catalog.test.tsx b/modules/resources/src/web/catalog.test.tsx index ae6ecbb..eba9448 100644 --- a/modules/resources/src/web/catalog.test.tsx +++ b/modules/resources/src/web/catalog.test.tsx @@ -40,15 +40,15 @@ async function render(language: string, children: ReactNode) { } } test("el catálogo muestra plantillas de serie y acción de alta sin formularios junto al listado", async () => { - for (const [language, create, copy] of [ - ["es", "Nueva plantilla", "Crear copia"], - ["en", "New template", "Create copy"], + for (const [language, create, actions] of [ + ["es", "Nueva plantilla", "Acciones"], + ["en", "New template", "Actions"], ]) { - assert.ok(language && create && copy); + assert.ok(language && create && actions); const html = await render(language, ); assert.ok(html.includes("Buzón de correo")); assert.ok(html.includes(create)); - assert.ok(html.includes(copy)); + assert.ok(html.includes(actions)); assert.equal(html.includes("Editar"), false); } diff --git a/modules/resources/src/web/resources-page.tsx b/modules/resources/src/web/resources-page.tsx index 71e6631..31b50ea 100644 --- a/modules/resources/src/web/resources-page.tsx +++ b/modules/resources/src/web/resources-page.tsx @@ -1,8 +1,12 @@ import { Button } from "@repo/shadcn-ui/components/button"; -import { Card, CardContent } from "@repo/shadcn-ui/components/card"; +import { + EntityAction, + EntityListHeader, + EntityRowActions, + EntityStatus, + EntityTable, +} from "@repo/shadcn-ui/components/entity-list"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { Input } from "@repo/shadcn-ui/components/input"; -import { Label } from "@repo/shadcn-ui/components/label"; import { useQuery } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { useId, useState } from "react"; @@ -24,8 +28,8 @@ export function ResourcesPage({ csrfToken: string; relatedContent?: (resource: ResourceDTO) => ReactNode; }) { - const { t } = useTranslation("resources"); - const searchId = useId(); + const { t, i18n } = useTranslation("resources"); + const _searchId = useId(); const [search, setSearch] = useState(""); const [editor, setEditor] = useState(); const [viewId, setViewId] = useState(); @@ -47,13 +51,20 @@ export function ResourcesPage({ ) ?? []; return (
-
-

{t("inventory")}

- -
-

{t("resourceScope")}

+ + {" "} + {" "} + + } + description={t("resourceScope")} + locale={i18n.language} + title={t("inventory")} + /> + {(query.isPending || catalog.isPending) &&

{t("loading")}

} {(query.isError || catalog.isError) && ( @@ -66,46 +77,68 @@ export function ResourcesPage({ {t("retry")} )} - - - - setSearch(event.target.value)} value={search} /> - {query.data && - (visible.length ? ( -
- {visible.map((item) => ( -
-
-

{item.values.name}

-

- {item.specification.templateName} ·{" "} - {t(item.values.active ? "active" : "inactive")} -

-
-
- - -
-
- ))} -
- ) : ( -

{t("empty")}

- ))} -
-
+ item.values.name, + hideable: false, + render: (item) => ( + + ), + }, + { + id: "template", + label: t("template"), + value: (item) => item.specification.templateName, + render: (item) => <>{item.specification.templateName}, + }, + { + id: "state", + label: t("status"), + value: (item) => t(item.values.active ? "active" : "inactive"), + render: (item) => ( + + {t(item.values.active ? "active" : "inactive")} + + ), + }, + { + id: "actions", + label: t("actions"), + hideable: false, + render: (item) => ( + + {" "} +
+ setViewId(item.id)}> + {t("viewResource")} + + setEditor(item.id)}> + {t("edit")} + +
{" "} +
+ ), + }, + ]} + emptyMessage={t("empty")} + items={visible} + loading={query.isPending || catalog.isPending} + locale={i18n.language} + onSearchChange={setSearch} + search={search} + searchPlaceholder={t("search")} + title={t("inventory")} + unavailable={(!query.data && query.isError) || (!catalog.data && catalog.isError)} + /> {editor && catalog.data && ( + }> + {label} + + + + + {children} + + + + + ); +} +export function EntityAction({ + children, + onClick, + disabled = false, + className, +}: { + children: ReactNode; + onClick: () => void; + disabled?: boolean; + className?: string; +}) { + return ( + + {children} + + ); +} +export function EntityStatus({ + children, + active = true, +}: { + children: ReactNode; + active?: boolean; +}) { + return ( + + + ); +} +export function EntityListPagination({ + locale, + page, + pages, + pageSize, + onPage, + onPageSize, + id, +}: { + locale: string; + page: number; + pages: number; + pageSize: number; + onPage: (page: number) => void; + onPageSize: (size: number) => void; + id: string; +}) { + const labels = entityListLabels(locale); + return ( + + ); +} diff --git a/packages/shadcn-ui/src/components/entity-list.tsx b/packages/shadcn-ui/src/components/entity-list.tsx new file mode 100644 index 0000000..ca891dd --- /dev/null +++ b/packages/shadcn-ui/src/components/entity-list.tsx @@ -0,0 +1,191 @@ +import type { ReactNode } from "react"; +import { useId } from "react"; + +import { useEntityTable } from "../hooks/use-entity-table"; +import { entityListLabels } from "../lib/entity-list"; + +import { Button } from "./button"; +import { EntityListPagination } from "./entity-list-controls"; +import { EntityTableConfiguration } from "./entity-table-configuration"; +import { EntityTableContent } from "./entity-table-content"; +import { Input } from "./input"; + +export { EntityAction, EntityRowActions, EntityStatus } from "./entity-list-controls"; + +export function EntityListHeader({ + title, + description, + actions, + locale = "es", +}: { + title: string; + description?: string; + actions?: ReactNode; + locale?: string; +}) { + return ( +
+
+

{title}

+

+ {description ?? + `${entityListLabels(locale).description} ${title.toLocaleLowerCase(locale)}.`} +

+
+ {actions &&
{actions}
} +
+ ); +} +export type EntityColumn = { + id: string; + label: string; + render: (item: T) => ReactNode; + value?: (item: T) => string | number | boolean | null | undefined; + hideable?: boolean; +}; +export function EntityTable({ + items, + columns, + title, + entityLabel = title, + locale = "es", + search, + onSearchChange, + searchPlaceholder, + filters, + filtersApplied = false, + onClearFilters, + filterKey = "", + loading = false, + unavailable = false, + emptyMessage, + rowClassName, +}: { + items: readonly T[]; + columns: readonly EntityColumn[]; + title: string; + entityLabel?: string; + locale?: string; + search?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + filters?: ReactNode; + filtersApplied?: boolean; + onClearFilters?: () => void; + filterKey?: string; + loading?: boolean; + unavailable?: boolean; + emptyMessage?: string; + rowClassName?: (item: T) => string; +}) { + const id = useId(); + const labels = entityListLabels(locale); + const { table, total, range, pagination, setPagination, currentSearch, setLocalSearch } = + useEntityTable({ items, columns, search, onSearchChange, filterKey }); + const info = entityTableInfo( + loading, + unavailable, + labels.loading, + labels.range(range.from, range.to, total, entityLabel.toLocaleLowerCase(locale)) + ); + const message = entityTableMessage( + loading, + unavailable, + Boolean(currentSearch || filtersApplied), + labels, + emptyMessage + ); + const clear = () => { + (onSearchChange ?? setLocalSearch)(""); + onClearFilters?.(); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + }; + return ( +
+
+
+ + (onSearchChange ?? setLocalSearch)(event.target.value)} + placeholder={searchPlaceholder ?? labels.placeholder} + value={currentSearch} + /> +
+
+ {filters} + {(currentSearch || filtersApplied) && ( + + )} +
+
+
+

+ {info} +

+ setPagination((previous) => ({ ...previous, pageIndex: 0 }))} + table={table} + /> +
+ setPagination((previous) => ({ ...previous, pageIndex: 0 }))} + rowClassName={rowClassName} + table={table} + title={title} + unavailable={unavailable} + /> +
+

{info}

+ setPagination({ ...pagination, pageIndex })} + onPageSize={(pageSize) => setPagination({ pageIndex: 0, pageSize })} + page={range.page} + pageSize={pagination.pageSize} + pages={range.pages} + /> +
+
+ ); +} + +function entityTableInfo( + loading: boolean, + unavailable: boolean, + loadingLabel: string, + info: string +) { + if (loading) { + return loadingLabel; + } + return unavailable ? "—" : info; +} +function entityTableMessage( + loading: boolean, + unavailable: boolean, + filtered: boolean, + labels: ReturnType, + emptyMessage: string | undefined +) { + if (loading) { + return labels.loading; + } + if (unavailable) { + return labels.unavailable; + } + return filtered ? labels.noResults : (emptyMessage ?? labels.empty); +} diff --git a/packages/shadcn-ui/src/components/entity-table-configuration.tsx b/packages/shadcn-ui/src/components/entity-table-configuration.tsx new file mode 100644 index 0000000..df7b048 --- /dev/null +++ b/packages/shadcn-ui/src/components/entity-table-configuration.tsx @@ -0,0 +1,85 @@ +import type { Table as TableModel } from "@tanstack/react-table"; +import { useId, useState } from "react"; + +import { entityListLabels } from "../lib/entity-list"; + +import { Button } from "./button"; +import { Checkbox } from "./checkbox"; +import { FormDialog } from "./form-dialog"; +import { SelectWithAction } from "./select-with-action"; + +export function EntityTableConfiguration({ + table, + locale, + onSort, +}: { + table: TableModel; + locale: string; + onSort: () => void; +}) { + const id = useId(); + const labels = entityListLabels(locale); + const [columnsOpen, setColumnsOpen] = useState(false); + const sorting = table.getState().sorting; + const columns = table.getAllLeafColumns().map((column) => ({ + id: column.id, + label: String(column.columnDef.header), + value: column.getCanSort(), + })); + const setSorting = table.setSorting; + return ( + <> +
+ + { + const [column, direction] = value.split(":"); + setSorting( + column && column !== "initial" ? [{ id: column, desc: direction === "desc" }] : [] + ); + onSort(); + }} + options={[ + { value: "initial", label: labels.initial }, + ...columns + .filter((column) => column.value) + .flatMap((column) => [ + { value: `${column.id}:asc`, label: `${column.label} · ${labels.ascending}` }, + { value: `${column.id}:desc`, label: `${column.label} · ${labels.descending}` }, + ]), + ]} + placeholder={labels.sort} + value={sorting[0] ? `${sorting[0].id}:${sorting[0].desc ? "desc" : "asc"}` : "initial"} + /> +
{" "} + +
+ {table.getAllLeafColumns().map((column) => ( + + ))} +
+
+ + ); +} diff --git a/packages/shadcn-ui/src/components/entity-table-content.tsx b/packages/shadcn-ui/src/components/entity-table-content.tsx new file mode 100644 index 0000000..d15a581 --- /dev/null +++ b/packages/shadcn-ui/src/components/entity-table-content.tsx @@ -0,0 +1,98 @@ +import type { Table as TableModel } from "@tanstack/react-table"; + +import type { EntityColumn } from "./entity-list"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table"; +export function EntityTableContent({ + table, + columns, + title, + loading, + unavailable, + message, + rowClassName, + onSort, +}: { + table: TableModel; + columns: readonly EntityColumn[]; + title: string; + loading: boolean; + unavailable: boolean; + message: string; + rowClassName: ((item: T) => string) | undefined; + onSort: () => void; +}) { + const visibleColumns = table.getVisibleLeafColumns(); + const total = table.getFilteredRowModel().rows.length; + return ( + + + + {visibleColumns.map((column) => { + const direction = column.getIsSorted(); + return ( + + {column.getCanSort() ? ( + + ) : ( + String(column.columnDef.header) + )} + + ); + })} + + + + {loading || unavailable ? ( + + + {message} + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {visibleColumns.map((column) => ( + + {columns.find((item) => item.id === column.id)?.render(row.original)} + + ))} + + )) + )} + {!(loading || unavailable || total) && ( + + + {message} + + + )} + +
+ ); +} diff --git a/packages/shadcn-ui/src/entity-list.test.tsx b/packages/shadcn-ui/src/entity-list.test.tsx new file mode 100644 index 0000000..e919386 --- /dev/null +++ b/packages/shadcn-ui/src/entity-list.test.tsx @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import { EntityTable } from "./components/entity-list"; +import { entityListPages, entityListRange } from "./lib/entity-list"; + +const items = Array.from({ length: 21 }, (_, index) => ({ + id: String(index), + name: `Persona ${String(index + 1).padStart(2, "0")}`, +})); +const columns = [ + { + id: "name", + label: "Nombre", + value: (item: (typeof items)[number]) => item.name, + render: (item: (typeof items)[number]) => item.name, + hideable: false, + }, +]; + +test("los rangos vacíos y la última página nunca muestran índices fuera del conjunto", () => { + assert.deepEqual(entityListRange(0, 5, 10), { page: 0, pages: 1, from: 0, to: 0 }); + assert.deepEqual(entityListRange(21, 2, 10), { page: 2, pages: 3, from: 21, to: 21 }); + assert.deepEqual(entityListRange(9, 2, 10), { page: 0, pages: 1, from: 1, to: 9 }); + assert.deepEqual(entityListPages(0, 12), [0, 1, 2, 3, 4]); + assert.deepEqual(entityListPages(11, 12), [7, 8, 9, 10, 11]); + assert.deepEqual(entityListPages(0, 1), [0]); +}); + +test("el listado pagina el conjunto y repite su rango conservando controles accesibles", () => { + const html = renderToStaticMarkup( + + ); + assert.equal(html.split("Mostrando 1-10 de 21 personas").length - 1, 2); + assert.ok(html.includes("Persona 10")); + assert.equal(html.includes("Persona 11"), false); + assert.ok(html.includes('aria-label="Página siguiente"')); + assert.ok(html.includes('aria-current="page"')); + assert.ok(html.includes('scope="col"')); + assert.ok(html.includes("Columnas")); + assert.ok(html.includes("Ordenar por")); +}); + +test("la búsqueda local reduce el conjunto completo antes de paginar y permite limpiarla", () => { + const html = renderToStaticMarkup( + + ); + assert.ok(html.includes("Mostrando 1-1 de 1 personas")); + assert.ok(html.includes("Persona 21")); + assert.ok(html.includes("Limpiar filtros")); + assert.equal(html.includes("Persona 01"), false); +}); + +test("una búsqueda controlada conserva los resultados filtrados por la pantalla", () => { + const html = renderToStaticMarkup( + undefined} + search="criterio externo" + title="Personas" + /> + ); + assert.ok(html.includes("Mostrando 1-1 de 1 personas")); + assert.ok(html.includes("Persona 01")); +}); + +test("carga, error y búsqueda vacía se distinguen de un listado sin registros", () => { + const render = (props: { loading?: boolean; unavailable?: boolean; search?: string }) => + renderToStaticMarkup( + + ); + assert.ok(render({}).includes("No hay personas.")); + assert.ok(render({ search: "Ana" }).includes("No hay resultados para estos criterios.")); + assert.ok(render({ loading: true }).includes('aria-busy="true"')); + assert.equal(render({ loading: true }).includes("No hay personas."), false); + assert.ok(render({ unavailable: true }).includes("No se ha podido cargar el listado.")); + assert.equal(render({ unavailable: true }).includes("Mostrando 0-0"), false); +}); + +test("el rango y la configuración también se traducen en inglés", () => { + const html = renderToStaticMarkup( + + ); + assert.ok(html.includes("Showing 0-0 of 0 people")); + assert.ok(html.includes("Columns")); + assert.ok(html.includes("Items per page")); +}); diff --git a/packages/shadcn-ui/src/hooks/use-entity-table.ts b/packages/shadcn-ui/src/hooks/use-entity-table.ts new file mode 100644 index 0000000..23d593e --- /dev/null +++ b/packages/shadcn-ui/src/hooks/use-entity-table.ts @@ -0,0 +1,92 @@ +import type { + ColumnDef, + PaginationState, + SortingState, + VisibilityState, +} from "@tanstack/react-table"; +import { + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useMemo, useState } from "react"; + +import type { EntityColumn } from "../components/entity-list"; +import { entityListRange } from "../lib/entity-list"; +export function useEntityTable({ + items, + columns, + search, + onSearchChange, + filterKey, +}: { + items: readonly T[]; + columns: readonly EntityColumn[]; + search: string | undefined; + onSearchChange: ((value: string) => void) | undefined; + filterKey: string; +}) { + const [localSearch, setLocalSearch] = useState(""); + const currentSearch = search ?? localSearch; + const [sorting, setSorting] = useState([]); + const [visibility, setVisibility] = useState({}); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const resetKey = `${currentSearch}\0${filterKey}`; + const [previousKey, setPreviousKey] = useState(resetKey); + if (previousKey !== resetKey) { + setPreviousKey(resetKey); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + } + const definitions = useMemo[]>( + () => + columns.map((column) => ({ + id: column.id, + header: column.label, + accessorFn: column.value ?? (() => ""), + enableSorting: !!column.value, + enableGlobalFilter: !!column.value, + enableHiding: column.hideable !== false, + })), + [columns] + ); + const table = useReactTable({ + data: [...items], + columns: definitions, + getRowId: (item) => item.id, + state: { + sorting, + columnVisibility: visibility, + pagination, + globalFilter: onSearchChange ? "" : currentSearch.trim(), + }, + onSortingChange: setSorting, + onColumnVisibilityChange: setVisibility, + onPaginationChange: setPagination, + autoResetPageIndex: false, + globalFilterFn: "includesString", + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + const total = table.getFilteredRowModel().rows.length; + const range = entityListRange(total, pagination.pageIndex, pagination.pageSize); + // Una actualización puede reducir el conjunto sin cambiar los filtros. + if (pagination.pageIndex !== range.page) { + setPagination({ ...pagination, pageIndex: range.page }); + } + + return { + table, + total, + range, + pagination, + setPagination, + sorting, + setSorting, + currentSearch, + setLocalSearch, + }; +} diff --git a/packages/shadcn-ui/src/lib/entity-list.ts b/packages/shadcn-ui/src/lib/entity-list.ts new file mode 100755 index 0000000..5bab1ba --- /dev/null +++ b/packages/shadcn-ui/src/lib/entity-list.ts @@ -0,0 +1,62 @@ +export function entityListLabels(locale = "es") { + return locale.startsWith("en") + ? { + search: "Quick search", + placeholder: "Search by name…", + clear: "Clear filters", + columns: "Columns", + sort: "Sort by", + initial: "Initial order", + ascending: "Ascending", + descending: "Descending", + previous: "Previous page", + next: "Next page", + pageSize: "Items per page", + page: "Page", + actions: "Actions", + loading: "Loading…", + unavailable: "Unable to load this list.", + empty: "No records.", + noResults: "No matching results.", + description: "View and manage", + range: (from: number, to: number, total: number, entity: string) => + `Showing ${from}-${to} of ${total} ${entity}`, + } + : { + search: "Búsqueda rápida", + placeholder: "Buscar por nombre…", + clear: "Limpiar filtros", + columns: "Columnas", + sort: "Ordenar por", + initial: "Orden inicial", + ascending: "Ascendente", + descending: "Descendente", + previous: "Página anterior", + next: "Página siguiente", + pageSize: "Elementos por página", + page: "Página", + actions: "Acciones", + loading: "Cargando…", + unavailable: "No se ha podido cargar el listado.", + empty: "No hay registros.", + noResults: "No hay resultados para estos criterios.", + description: "Consulta y gestiona", + range: (from: number, to: number, total: number, entity: string) => + `Mostrando ${from}-${to} de ${total} ${entity}`, + }; +} + +export function entityListRange(total: number, pageIndex: number, pageSize: number) { + const pages = Math.max(1, Math.ceil(total / pageSize)); + const page = Math.max(0, Math.min(pageIndex, pages - 1)); + return { + page, + pages, + from: total ? page * pageSize + 1 : 0, + to: Math.min((page + 1) * pageSize, total), + }; +} +export function entityListPages(page: number, pages: number) { + const start = Math.max(0, Math.min(page - 2, pages - 5)); + return Array.from({ length: Math.min(5, pages) }, (_, index) => start + index); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 982d0cb..47d9128 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -457,6 +457,9 @@ importers: '@base-ui/react': specifier: 1.8.0 version: 1.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-table': + specifier: 8.21.3 + version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: 0.7.1 version: 0.7.1