Repaso visual a los listados

This commit is contained in:
David Arranz 2026-10-07 17:27:23 +02:00
parent 5144073481
commit ecfd689e44
47 changed files with 4109 additions and 1891 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.fontFamily": "'C64 Keyboard Regular', 'Fira Code Retina', 'Fira Code'"
}

View File

@ -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

View File

@ -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;

View File

@ -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.

View File

@ -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.

View File

@ -37,7 +37,7 @@ export function AssetSupport({
<section className="space-y-3 rounded-md border p-4">
<header className="flex flex-wrap items-center justify-between gap-3">
<h3 className="font-semibold">{t("support.title")}</h3>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button onClick={() => setToolsOpen(true)} size="sm" variant="outline">
{t("support.tools")}
</Button>
@ -88,7 +88,7 @@ export function AssetSupport({
(retired || !tools.find((tool) => tool.id === item.values.toolId)?.active) && (
<p className="text-sm text-amber-700">{t("support.review")}</p>
)}
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
setEdit(item);

View File

@ -1,16 +1,12 @@
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 { 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
@ -29,8 +25,8 @@ type Editor = {
copy?: CatalogItemDTO;
};
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
const { t } = useTranslation("assets");
const searchId = useId();
const { t, i18n } = useTranslation("assets");
const _searchId = useId();
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
const [search, setSearch] = useState("");
@ -56,19 +52,18 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">
{t(scope === "global" ? "title" : "companyTitle")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t(scope === "global" ? "introduction" : "companyIntroduction")}
</p>
</div>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
</Button>
</div>
<EntityListHeader
actions={
<>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
</Button>
</>
}
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
locale={i18n.language}
title={t(scope === "global" ? "title" : "companyTitle")}
/>
<nav aria-label={t("title")} className="flex gap-2">
{(["template", "field", "relation"] as const).map((kind) => (
<Button
@ -97,51 +92,111 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
{t("retry")}
</Button>
)}
<Card>
<CardContent className="space-y-4 pt-6">
<div className="space-y-2">
<Label htmlFor={searchId}>{t("search")}</Label>
<Input
id={searchId}
onChange={(event) => setSearch(event.target.value)}
value={search}
/>
</div>
{query.data &&
(visible.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("origin")}</TableHead>
<TableHead>
{t({ field: "type", template: "fields", relation: "inverseName" }[tab])}
</TableHead>
<TableHead>{t("active")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((item) => (
<CatalogRow
item={item}
items={items}
key={item.id}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
))}
</TableBody>
</Table>
) : (
<p className="text-muted-foreground">{t("empty")}</p>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (item) => item.values.name,
hideable: false,
render: (item) => (
<CatalogCell
column="name"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
{
id: "origin",
label: t("origin"),
value: (item) => t(item.builtin ? "builtin" : "custom"),
render: (item) => (
<CatalogCell
column="origin"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
{
id: "details",
label: t({ field: "type", template: "fields", relation: "inverseName" }[tab]),
render: (item) => (
<CatalogCell
column="details"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
{
id: "state",
label: t("active"),
value: (item) => item.effectiveActive,
render: (item) => (
<CatalogCell
column="state"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<CatalogCell
column="actions"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
]}
emptyMessage={t("empty")}
entityLabel={t({ field: "fields", template: "templates", relation: "relations" }[tab])}
items={visible}
key={tab}
loading={query.isPending}
locale={i18n.language}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("search")}
title={t(scope === "global" ? "title" : "companyTitle")}
unavailable={!query.data && query.isError}
/>
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
{editor && (
<FormDialog
@ -185,7 +240,8 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
</div>
);
}
function CatalogRow({
function CatalogCell({
column,
item,
scope,
items,
@ -195,6 +251,7 @@ function CatalogRow({
onView,
onStatus,
}: {
column: "name" | "origin" | "details" | "state" | "actions";
item: CatalogItemDTO;
scope: string;
items: readonly CatalogItemDTO[];
@ -206,55 +263,49 @@ function CatalogRow({
}) {
const { t } = useTranslation("assets");
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
const values = item.values;
const inactiveFields =
values.kind === "template" &&
values.fields.some(
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
);
return (
<TableRow>
<TableCell>
<p className="font-medium">{values.name}</p>
{values.description && (
<p className="text-xs text-muted-foreground">{values.description}</p>
)}
{inactiveFields && (
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
)}
</TableCell>
<TableCell>
{t(item.builtin ? "builtin" : "custom")}
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
</TableCell>
<TableCell>
{values.kind === "field"
? t(values.type)
: values.kind === "relation"
? values.inverseName
: t("fieldCount", { count: values.fields.length })}
</TableCell>
<TableCell>
{t(item.effectiveActive ? "active" : "inactive")}
{scope !== "global" && !item.companyId && (
<p className="text-xs text-muted-foreground">
{t(item.overridden ? "overridden" : "inherited")}
</p>
)}
</TableCell>
<TableCell>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</TableCell>
</TableRow>
);
const _values = item.values;
switch (column) {
case "name":
return <CatalogName item={item} items={items} onView={onView} />;
case "origin":
return (
<>
{t(item.builtin ? "builtin" : "custom")}
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
</>
);
case "details":
return <CatalogDetails item={item} />;
case "state":
return (
<>
<EntityStatus active={item.effectiveActive}>
{t(item.effectiveActive ? "active" : "inactive")}
</EntityStatus>
{scope !== "global" && !item.companyId && (
<p className="text-xs text-muted-foreground">
{t(item.overridden ? "overridden" : "inherited")}
</p>
)}
</>
);
case "actions":
return (
<>
<EntityRowActions label={t("actions")}>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</EntityRowActions>
</>
);
}
}
function editorTitle(editor: Editor) {
@ -286,33 +337,63 @@ function CatalogRowActions({
}) {
const { t } = useTranslation("assets");
return (
<div className="flex flex-wrap gap-2">
<Button onClick={onView} size="sm" variant="outline">
{t("view")}
</Button>
{editable && (
<Button onClick={onEdit} size="sm" variant="outline">
{t("edit")}
</Button>
)}
{item.values.kind === "template" && (
<Button onClick={onCopy} size="sm" variant="outline">
{t("copy")}
</Button>
)}
<Button
disabled={pending}
onClick={() => onStatus(!item.effectiveActive)}
size="sm"
variant="outline"
>
<div className="contents">
<EntityAction onClick={onView}>{t("view")}</EntityAction>
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>
{t(item.effectiveActive ? "deactivate" : "activate")}
</Button>
</EntityAction>
{item.overridden && (
<Button disabled={pending} onClick={() => onStatus(null)} size="sm" variant="ghost">
<EntityAction disabled={pending} onClick={() => onStatus(null)}>
{t("reset")}
</Button>
</EntityAction>
)}
</div>
);
}
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 (
<>
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={onView}
type="button"
>
{values.name}
</button>
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}
{inactiveFields && (
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
)}
</>
);
}
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 });
}

View File

@ -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, <AssetCatalogPage csrfToken="test" scope="global" />);
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("<form"), false);
assert.equal(html.includes(">Editar</button>"), false);
}

View File

@ -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 (
<div className="inventory-layout" data-detail-open={detailOpen && Boolean(selected)}>
<div className="inventory-list space-y-5">
<header className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("inventory")}</h2>
<div className="flex flex-wrap items-center justify-end gap-2">
{!detailOpen && (
<Button
className="asset-desktop-action"
onClick={() => setDetailOpen(true)}
variant="outline"
>
{t("showAssetPanel")}
</Button>
)}
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
{t("newAsset")}
</Button>
</div>
</header>
<Input
aria-label={t("search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<div className="flex flex-wrap items-center justify-end gap-2">
{!detailOpen && (
<Button
className="asset-desktop-action"
onClick={() => setDetailOpen(true)}
variant="outline"
>
{t("showAssetPanel")}
</Button>
)}
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
{t("newAsset")}
</Button>
</div>{" "}
</>
}
locale={i18n.language}
title={t("inventory")}
/>
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error} />
<CatalogError error={catalog.error} />
@ -94,68 +100,103 @@ export function AssetInventoryPage({
</Button>
)}
{query.data && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(assets.length ? "noResults" : "noAssets")}
</p>
)}
{filtered.map((asset) => (
<div
className={`inventory-asset-row flex flex-wrap items-center justify-between gap-3 py-4 ${selectedRowClass(asset.id, selected?.id, detailOpen)}`}
key={asset.id}
>
<div>
<h3 className="font-medium">
<button
aria-pressed={selected?.id === asset.id && detailOpen}
className="inventory-asset-name"
onClick={() => {
setSelectedId(asset.id);
setDetailOpen(true);
if (window.matchMedia("(max-width: 1099px)").matches) {
setViewId(asset.id);
}
}}
type="button"
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (asset) => asset.values.name,
hideable: false,
render: (asset) => (
<button
aria-pressed={selected?.id === asset.id && detailOpen}
className="inventory-asset-name text-left font-medium"
onClick={() => {
setSelectedId(asset.id);
setDetailOpen(true);
if (window.matchMedia("(max-width: 1099px)").matches) {
setViewId(asset.id);
}
}}
type="button"
>
{asset.values.name}
</button>
),
},
{
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) => (
<EntityStatus active={asset.values.status !== "retired"}>
{t(asset.values.status)}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction
className="asset-mobile-action"
onClick={() => setViewId(asset.id)}
>
{asset.values.name}
</button>
</h3>
<p className="text-sm text-muted-foreground">
{asset.specification.templateName} · {t(asset.values.status)}
</p>
<p className="text-sm text-muted-foreground">
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
t("noAssignment")}{" "}
·{" "}
{lookups.people.find((person) => person.id === asset.values.personId)?.name ??
t("noAssignment")}
</p>
</div>
<div className="flex gap-2">
<Button
className="asset-mobile-action"
onClick={() => setViewId(asset.id)}
size="sm"
variant="outline"
>
{t("viewAsset")}
</Button>
<Button
disabled={!catalog.data}
onClick={() => setEditor(asset.id)}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
{t("viewAsset")}
</EntityAction>
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
{t("edit")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
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")}
/>
)}
</div>
<AssetSelectionPanel

View File

@ -1,4 +1,11 @@
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";
@ -9,22 +16,29 @@ import type { SupportToolDTO } from "../common/support";
import { supportApi, supportKeys } from "./support-api";
import { SupportToolForm } from "./support-tool-form";
export function SupportTools({ csrfToken }: { csrfToken: string }) {
const { t } = useTranslation("assets");
const { t, i18n } = useTranslation("assets");
const query = useQuery({ queryKey: supportKeys.tools, queryFn: () => supportApi.tools() });
const [edit, setEdit] = useState<SupportToolDTO | undefined>();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<header className="flex justify-end">
<Button
onClick={() => {
setEdit(undefined);
setOpen(true);
}}
>
{t("support.newTool")}
</Button>
</header>
<EntityListHeader
actions={
<>
{" "}
<Button
onClick={() => {
setEdit(undefined);
setOpen(true);
}}
>
{t("support.newTool")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("support.tools")}
/>
{query.isPending && <p role="status">{t("support.loading")}</p>}
{query.isError && (
<div role="alert">
@ -39,25 +53,55 @@ export function SupportTools({ csrfToken }: { csrfToken: string }) {
</Button>
</div>
)}
<ul className="divide-y">
{query.data?.tools.map((tool) => (
<li className="flex items-center justify-between gap-3 py-3" key={tool.id}>
<span>
{tool.name} · {t(tool.active ? "support.active" : "inactive")}
{tool.builtin ? ` · ${t("support.builtin")}` : ""}
</span>
<Button
onClick={() => {
setEdit(tool);
setOpen(true);
}}
variant="outline"
>
{t("edit")}
</Button>
</li>
))}
</ul>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (tool) => tool.name,
hideable: false,
render: (tool) => <span className="font-medium">{tool.name}</span>,
},
{
id: "state",
label: t("status"),
value: (tool) => tool.active,
render: (tool) => (
<EntityStatus active={tool.active}>
{t(tool.active ? "support.active" : "inactive")}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
<EntityAction
onClick={() => {
setEdit(tool);
setOpen(true);
}}
>
{t("edit")}
</EntityAction>
</EntityRowActions>
),
},
]}
items={query.data?.tools ?? []}
loading={query.isPending}
locale={i18n.language}
title={t("support.tools")}
unavailable={query.isError}
/>
<FormDialog
cancelLabel={t("cancel")}
formActions

View File

@ -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";
@ -44,7 +49,7 @@ export function WorkstationsPage({
(value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
)
);
const summary = (item: WorkstationDTO) => (
const _summary = (item: WorkstationDTO) => (
<>
<h3 className="font-medium">
{item.values.name} · {t(item.values.active ? "active" : "inactive")}
@ -66,18 +71,19 @@ export function WorkstationsPage({
);
return (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("workstations")}</h2>
<Button disabled={!query.data} onClick={() => setEditor("new")}>
{t("newWorkstation")}
</Button>
</header>
<Input
aria-label={t("search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!query.data} onClick={() => setEditor("new")}>
{t("newWorkstation")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("workstations")}
/>
{query.isPending && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error} />
{query.isError && (
@ -91,28 +97,94 @@ export function WorkstationsPage({
</Button>
)}
{query.data && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(stations.length ? "noResults" : "noWorkstations")}
</p>
)}
{filtered.map((item) => (
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
<div className="min-w-0 break-words">{summary(item)}</div>
<div className="flex gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("workstationHistory")}
</Button>
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (item) => item.values.name,
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)}
type="button"
>
{item.values.name}
</button>
),
},
{
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) => (
<EntityStatus active={item.values.active}>
{t(item.values.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("workstationHistory")}
</EntityAction>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("noWorkstations")}
items={filtered}
locale={i18n.language}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("search")}
title={t("workstations")}
/>
)}
{editor && (
<FormDialog

View File

@ -1,5 +1,8 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { TableCell, TableRow } from "@repo/shadcn-ui/components/table";
import {
EntityAction,
EntityRowActions,
EntityStatus,
} from "@repo/shadcn-ui/components/entity-list";
import { useTranslation } from "react-i18next";
import type { AccessFieldsDTO, AccessItemDTO, AccessResponseDTO, PersonDTO } from "../../common";
@ -17,7 +20,11 @@ function EntryState({ value }: { value: AccessFieldsDTO }) {
}
return (
<>
<p>{state}</p>
<EntityStatus
active={"active" in value ? value.active : "endDate" in value && !value.endDate}
>
{state}
</EntityStatus>
{"configuration" in value && value.configuration && (
<p className="text-sm">{t(value.configuration)}</p>
)}
@ -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 (
<TableRow>
<TableCell>
<p className="font-medium">{accessLabel(item, data.items, people)}</p>
{!own && <p className="text-xs text-muted-foreground">{t("global")}</p>}
</TableCell>
<TableCell>
<EntryState value={item.values} />
{coverage && <p className="text-sm">{t(coverage.covered ? "covered" : "notCovered")}</p>}
{coverage && !coverage.covered && missing && (
<p className="text-xs text-muted-foreground">
{t("missingPermissions")}: {missing}
</p>
)}
</TableCell>
<TableCell>
{reviews.length
? reviews.map((review) => (
<p className="text-sm" key={review.reason}>
{t(`reviews.${review.reason}`)}
</p>
))
: "—"}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button disabled={!own} onClick={onEdit} size="sm" variant="outline">
{t("edit")}
</Button>
<Button onClick={onHistory} size="sm" variant="ghost">
{t("history")}
</Button>
</div>
</TableCell>
</TableRow>
);
switch (column) {
case "name":
return (
<>
{" "}
<p className="font-medium">{accessLabel(item, data.items, people)}</p>
{!own && <p className="text-xs text-muted-foreground">{t("global")}</p>}{" "}
</>
);
case "state":
return (
<>
{" "}
<EntryState value={item.values} />
{coverage && <p className="text-sm">{t(coverage.covered ? "covered" : "notCovered")}</p>}
{coverage && !coverage.covered && missing && (
<p className="text-xs text-muted-foreground">
{t("missingPermissions")}: {missing}
</p>
)}{" "}
</>
);
case "review":
return (
<>
{" "}
{reviews.length
? reviews.map((review) => (
<p className="text-sm" key={review.reason}>
{t(`reviews.${review.reason}`)}
</p>
))
: "—"}{" "}
</>
);
case "actions":
return (
<>
{" "}
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction disabled={!own} onClick={onEdit}>
{t("edit")}
</EntityAction>
<EntityAction onClick={onHistory}>{t("history")}</EntityAction>
</div>{" "}
</EntityRowActions>{" "}
</>
);
}
}

View File

@ -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<string>();
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 (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">
{t(scope === "global" ? "catalogTitle" : "title")}
</h2>
<p className="text-sm text-muted-foreground">
{t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => setHelpOpen(true)} variant="outline">
{t("helpButton")}
</Button>
<Button disabled={!data} onClick={() => setEditor("new")}>
{t(createLabels[kind])}
</Button>
</div>
</div>
<EntityListHeader
actions={
<>
{" "}
<div className="flex gap-2">
<Button onClick={() => setHelpOpen(true)} variant="outline">
{t("helpButton")}
</Button>
<Button disabled={!data} onClick={() => setEditor("new")}>
{t(createLabels[kind])}
</Button>
</div>{" "}
</>
}
description={t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
locale={i18n.language}
title={t(`kinds.${kind}`)}
/>
<FormDialog
cancelLabel={t("helpClose")}
onOpenChange={setHelpOpen}
@ -125,52 +124,81 @@ export function AccessWorkspacePage({
)}
{data && (
<>
<div className="max-w-sm space-y-2">
<Label htmlFor={searchId}>{t("search")}</Label>
<Input
id={searchId}
onChange={(event) => setSearch(event.target.value)}
value={search}
/>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("state")}</TableHead>
<TableHead>{t("review")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items
.filter(
(item) =>
item.values.kind === kind &&
accessLabel(item, data.items, people)
.toLocaleLowerCase()
.includes(search.trim().toLocaleLowerCase())
)
.map((item) => (
<AccessRow
data={data}
item={item}
key={item.id}
onEdit={() => setEditor(item.id)}
onHistory={() => setHistory(item.id)}
people={people}
scope={scope}
/>
))}
</TableBody>
</Table>
{!data.items.some((item) => item.values.kind === kind) && (
<p className="py-4 text-muted-foreground">{t("empty")}</p>
)}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (item) => accessLabel(item, data.items, people),
hideable: false,
render: (item) => (
<AccessCell
column="name"
data={data}
item={item}
onEdit={() => setEditor(item.id)}
onHistory={() => setHistory(item.id)}
people={people}
scope={scope}
/>
),
},
{
id: "state",
label: t("state"),
render: (item) => (
<AccessCell
column="state"
data={data}
item={item}
onEdit={() => setEditor(item.id)}
onHistory={() => setHistory(item.id)}
people={people}
scope={scope}
/>
),
},
{
id: "review",
label: t("review"),
render: (item) => (
<AccessCell
column="review"
data={data}
item={item}
onEdit={() => setEditor(item.id)}
onHistory={() => setHistory(item.id)}
people={people}
scope={scope}
/>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<AccessCell
column="actions"
data={data}
item={item}
onEdit={() => 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}`)}
/>
<FormDialog
cancelLabel={t("cancel")}
formActions

View File

@ -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";
@ -26,7 +31,7 @@ export function ApplicationConfigurationsPage({
csrfToken: string;
initialProfileId?: string;
}) {
const { t } = useTranslation("companies");
const { t, i18n } = useTranslation("companies");
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
@ -71,18 +76,19 @@ export function ApplicationConfigurationsPage({
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
return (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("applications.title")}</h2>
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
{t("applications.new")}
</Button>
</header>
<Input
aria-label={t("applications.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("applications.search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
{t("applications.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("applications.title")}
/>
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
<p role="status">{t("loading")}</p>
)}
@ -105,42 +111,84 @@ export function ApplicationConfigurationsPage({
</Button>
)}
{ready && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">{t("applications.empty")}</p>
)}
{filtered.map((item) => (
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
<div>
<h3 className="font-medium">{resource(item.values.resourceId)}</h3>
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
<p className="text-sm">
{t(`applications.${item.values.delivery}`)} · {t(`mail.${item.values.state}`)}
</p>
{item.values.version && (
<p className="text-sm">
{t("applications.versionValue", { version: item.values.version })}
</p>
)}
{item.needsReview && (
<p className="text-sm text-amber-700">{t("applications.review")}</p>
)}
</div>
<div className="flex items-start gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("mail.history")}
</Button>
{!["removed", "never_configured"].includes(item.values.state) && (
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("applications.title"),
value: (item) => resource(item.values.resourceId),
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)}
type="button"
>
{resource(item.values.resourceId)}
</button>
),
},
{
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) => (
<EntityStatus active={item.values.state === "configured"}>
{t(`mail.${item.values.state}`)}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("applications.empty")}
items={filtered}
locale={i18n.language}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("applications.search")}
title={t("applications.title")}
/>
)}
{editor && (
<FormDialog

View File

@ -66,7 +66,7 @@ test("aplicaciones: listado sin formularios, alta en cabecera y traducciones", a
assert.ok(html.includes(title ?? ""));
assert.ok(html.includes(action ?? ""));
assert.ok(!html.includes("<form"));
assert.ok(!html.includes("applications."));
assert.equal(/(?:>|")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);
}
});

View File

@ -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 (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("certificates.title")}</h2>
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
{t("certificates.new")}
</Button>
</header>
<Input
aria-label={t("certificates.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("certificates.search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
{t("certificates.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("certificates.title")}
/>
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
<p role="status">{t("loading")}</p>
)}
@ -105,51 +111,91 @@ export function CertificateConfigurationsPage({
</Button>
)}
{ready && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">{t("certificates.empty")}</p>
)}
{filtered.map((item) => (
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
<div>
<h3 className="font-medium">{resource(item.values.resourceId)}</h3>
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
<p className="text-sm">{t(`mail.${item.values.state}`)}</p>
{item.values.location && (
<p className="text-sm">
{t("certificates.locationValue", { location: item.values.location })}
</p>
)}
{item.expiresOn && (
<p className="text-sm">
{t("certificates.expiryValue", {
date: new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(item.expiresOn)),
})}
{item.expired ? ` · ${t("certificates.expired")}` : ""}
</p>
)}
{item.needsReview && (
<p className="text-sm text-amber-700">{t("certificates.review")}</p>
)}
</div>
<div className="flex items-start gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("mail.history")}
</Button>
{!["removed", "never_configured"].includes(item.values.state) && (
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("certificates.title"),
value: (item) => resource(item.values.resourceId),
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)}
type="button"
>
{resource(item.values.resourceId)}
</button>
),
},
{
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) => (
<EntityStatus active={item.values.state === "configured"}>
{t(`mail.${item.values.state}`)}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("certificates.empty")}
items={filtered}
locale={i18n.language}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("certificates.search")}
title={t("certificates.title")}
/>
)}
{editor && (
<FormDialog

View File

@ -1,16 +1,12 @@
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 { 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 { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
@ -37,12 +33,12 @@ export function CompaniesPage({
csrfToken: string;
onSelectCompany?: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const { t, i18n } = useTranslation("companies");
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
const [selectedId, setSelectedId] = useState<string | undefined>();
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 (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="mt-2 text-muted-foreground">{t("help")}</p>
</div>
<Button onClick={() => setCreateOpen(true)}>{t("newCompany")}</Button>
</div>
<div className="space-y-2">
<Label htmlFor={id}>{t("search")}</Label>
<Input
className="max-w-md"
id={id}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("searchPlaceholder")}
value={search}
/>
</div>
<EntityListHeader
actions={
<>
{" "}
<Button onClick={() => setCreateOpen(true)}>{t("newCompany")}</Button>{" "}
</>
}
description={t("help")}
locale={i18n.language}
title={t("title")}
/>
{query.isPending && <p role="status">{t("loading")}</p>}
<CompanyFeedback error={query.error} />
{query.isError && (
@ -95,40 +86,52 @@ export function CompaniesPage({
</Button>
)}
{query.data && (
<Card>
<CardContent className="pt-6">
{companies.length === 0 ? (
<p className="text-muted-foreground">{t(search ? "noResults" : "empty")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.map((company) => (
<TableRow key={company.id}>
<TableCell className="font-medium">{company.name}</TableCell>
<TableCell>{t(company.active ? "active" : "inactive")}</TableCell>
<TableCell>
<Button
onClick={() => selectCompany(company.id)}
size="sm"
variant="outline"
>
{t("open")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (company) => company.name,
hideable: false,
render: (company) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => selectCompany(company.id)}
type="button"
>
{company.name}
</button>
),
},
{
id: "state",
label: t("status"),
value: (company) => t(company.active ? "active" : "inactive"),
render: (company) => (
<EntityStatus active={company.active}>
{t(company.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (company) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => selectCompany(company.id)}>{t("open")}</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("empty")}
items={companies}
locale={i18n.language}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("searchPlaceholder")}
title={t("title")}
/>
)}
<FormDialog
cancelLabel={t("cancel")}

View File

@ -1,7 +1,12 @@
import { AssetCatalogPage } from "@metis/assets/client";
import { ResourceCatalogPage, ResourcesPage } 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,
EntityTable,
} from "@repo/shadcn-ui/components/entity-list";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import {
BoxIcon,
@ -43,39 +48,50 @@ import { PeopleSection } from "./people-section";
import { SiteForm } from "./site-form";
function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
const { t } = useTranslation("companies");
const { t, i18n } = useTranslation("companies");
const [createOpen, setCreateOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | undefined>();
const selected = company.sites.find((site) => site.id === selectedId);
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="mb-4 flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold">{t("sites")}</h2>
<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>
</div>
{company.sites.length === 0 ? (
<p className="text-muted-foreground">{t("noSites")}</p>
) : (
<div className="divide-y">
{company.sites.map((site) => (
<div className="flex items-start justify-between gap-4 py-4" key={site.id}>
<div className="min-w-0">
<h3 className="font-medium">{site.name}</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
{site.address ?? t("noAddress")}
</p>
</div>
<Button onClick={() => setSelectedId(site.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<EntityListHeader
actions={<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>}
locale={i18n.language}
title={t("sites")}
/>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (site) => site.name,
hideable: false,
render: (site) => <span className="font-medium">{site.name}</span>,
},
{
id: "address",
label: t("addressOptional"),
value: (site) => site.address,
render: (site) => (
<span className="whitespace-pre-wrap">{site.address ?? t("noAddress")}</span>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (site) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setSelectedId(site.id)}>{t("edit")}</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("noSites")}
items={company.sites}
locale={i18n.language}
title={t("sites")}
/>
<FormDialog
cancelLabel={t("cancel")}
formActions

View File

@ -1,18 +1,16 @@
import { zodResolver } from "@hookform/resolvers/zod";
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 {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityStatus,
EntityTable,
} from "@repo/shadcn-ui/components/entity-list";
import { FormActions, 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";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
@ -106,70 +104,88 @@ export function DepartmentSection({
company: CompanyDTO;
csrfToken: string;
}) {
const { t } = useTranslation("companies");
const { t, i18n } = useTranslation("companies");
const [editor, setEditor] = useState<string | undefined>();
const selected = company.departments.find((item) => item.id === editor);
return (
<Card>
<CardContent className="space-y-4 pt-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-lg font-semibold">{t("departments")}</h2>
<Button onClick={() => setEditor("new")}>{t("newDepartment")}</Button>
</div>
{company.departments.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("people")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{company.departments.map((department) => (
<TableRow key={department.id}>
<TableCell>{department.name}</TableCell>
<TableCell>{t(department.active ? "active" : "inactive")}</TableCell>
<TableCell>
{
company.assignments.filter(
(item) => item.departmentId === department.id && item.endDate === null
).length
}
</TableCell>
<TableCell>
<Button onClick={() => setEditor(department.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-muted-foreground">{t("noDepartments")}</p>
)}
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open={editor !== undefined}
title={t(selected ? "editDepartment" : "newDepartment")}
>
<DepartmentForm
companyId={company.id}
csrfToken={csrfToken}
department={selected}
key={editor}
onSaved={() => setEditor(undefined)}
/>
</FormDialog>
</CardContent>
</Card>
<div className="space-y-5">
<EntityListHeader
actions={<Button onClick={() => setEditor("new")}>{t("newDepartment")}</Button>}
locale={i18n.language}
title={t("departments")}
/>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (item) => item.name,
hideable: false,
render: (item) => <>{item.name}</>,
},
{
id: "state",
label: t("status"),
value: (item) => t(item.active ? "active" : "inactive"),
render: (item) => (
<EntityStatus active={item.active}>
{t(item.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("noDepartments")}
items={company.departments}
locale={i18n.language}
title={t("departments")}
/>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open={editor !== undefined}
title={t(selected ? "editDepartment" : "newDepartment")}
>
<DepartmentForm
companyId={company.id}
csrfToken={csrfToken}
department={selected}
key={editor}
onSaved={() => setEditor(undefined)}
/>
</FormDialog>
</div>
);
}

View File

@ -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({
)}
<Card>
<CardContent className="space-y-4 pt-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h3 className="text-lg font-semibold">{t("departmentAssignments")}</h3>
<Button disabled={!person.active} onClick={() => setEditor("new")}>
{t("addAssignment")}
</Button>
</div>
{assignments.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("department")}</TableHead>
<TableHead>{t("position")}</TableHead>
<TableHead>{t("startDate")}</TableHead>
<TableHead>{t("endDate")}</TableHead>
<TableHead>{t("primary")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((item) => (
<TableRow key={item.id}>
<TableCell>
{departmentName(item.departmentId)}
{company.departments.find((department) => department.id === item.departmentId)
?.active === false && (
<span className="block text-xs text-muted-foreground">
{t("inactiveDepartment")}
</span>
)}
</TableCell>
<TableCell>{item.position ?? "—"}</TableCell>
<TableCell>{item.startDate}</TableCell>
<TableCell>{item.endDate ?? t("currentAssignment")}</TableCell>
<TableCell>{item.isPrimary ? t("yes") : "—"}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
<Button onClick={() => setHistoryId(item.id)} size="sm" variant="ghost">
{t("history")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-muted-foreground">{t("noAssignments")}</p>
)}
<EntityListHeader
actions={
<Button disabled={!person.active} onClick={() => setEditor("new")}>
{t("addAssignment")}
</Button>
}
locale={i18n.language}
title={t("departmentAssignments")}
/>
<EntityTable
columns={[
{
id: "department",
label: t("department"),
value: (item) => departmentName(item.departmentId),
hideable: false,
render: (item) => (
<>
{departmentName(item.departmentId)}
{company.departments.find((entry) => entry.id === item.departmentId)?.active ===
false && <p className="text-xs">{t("inactiveDepartment")}</p>}
</>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
<EntityAction onClick={() => setHistoryId(item.id)}>
{t("history")}
</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("noAssignments")}
items={assignments}
locale={i18n.language}
title={t("departmentAssignments")}
/>
{!company.departments.some((item) => item.active) && (
<p className="text-sm text-muted-foreground">{t("createDepartmentFirst")}</p>
)}
@ -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 (
<div className="space-y-4">
{personEditor}
<div className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-lg font-semibold">{t("people")}</h2>
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="space-y-2">
<Label htmlFor={filterId}>{t("searchPerson")}</Label>
<Input id={filterId} onChange={(event) => setSearch(event.target.value)} value={search} />
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={contactsOnly}
id={`${filterId}-contact`}
onCheckedChange={(checked) => setContactsOnly(checked === true)}
/>
<Label htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</Label>
</div>
</div>
<Card>
<CardContent className="pt-6">
{people.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("position")}</TableHead>
<TableHead>{t("departments")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{people.map((person) => (
<TableRow key={person.id}>
<TableCell>
<span className="font-medium">{person.name}</span>
{person.isContact && (
<span className="block text-xs text-muted-foreground">
{t("isContact")}
</span>
)}
</TableCell>
<TableCell>{person.position ?? "—"}</TableCell>
<TableCell>
{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(", ") || "—"}
</TableCell>
<TableCell>{t(person.active ? "active" : "inactive")}</TableCell>
<TableCell>
<Button
onClick={() => setSelection({ mode: "detail", id: person.id })}
size="sm"
variant="outline"
>
{t("open")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-muted-foreground">
{t(search || contactsOnly ? "noResults" : "noPeople")}
</p>
)}
</CardContent>
</Card>
<EntityListHeader
actions={
<>
{" "}
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>{" "}
</>
}
locale={i18n.language}
title={t("people")}
/>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (person) => person.name,
hideable: false,
render: (person) => (
<>
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setSelection({ mode: "detail", id: person.id })}
type="button"
>
{person.name}
</button>
{person.isContact && (
<p className="text-xs text-muted-foreground">{t("isContact")}</p>
)}
</>
),
},
{
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) => (
<EntityStatus active={person.active}>
{t(person.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (person) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setSelection({ mode: "detail", id: person.id })}>
{t("open")}
</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("noPeople")}
filterKey={String(contactsOnly)}
filters={
<div className="flex items-center gap-2">
<Checkbox
checked={contactsOnly}
id={`${filterId}-contact`}
onCheckedChange={(checked) => setContactsOnly(checked === true)}
/>
<Label htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</Label>
</div>
}
filtersApplied={contactsOnly}
items={people}
locale={i18n.language}
onClearFilters={() => setContactsOnly(false)}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("searchPerson")}
title={t("people")}
/>
</div>
);
}

View File

@ -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<string>(),[view,setView]=useState<string>(),[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 <div className="space-y-5">
<header className="flex flex-wrap justify-between items-center gap-4"><h2 className="text-xl font-semibold">{t("folders.title")}</h2>
<Button disabled={!ready} onClick={()=>setEditor("new")}>{t("folders.new")}</Button></header>
<Input aria-label={t("folders.search")} placeholder={t("folders.search")} value={search} onChange={event=>setSearch(event.target.value)} />
{(query.isPending || access.isPending || profiles.isPending) && <p role="status">{t("loading")}</p>}
<FolderFeedback error={query.error ?? access.error ?? profiles.error} />
{(query.isError || access.isError || profiles.isError) && <Button variant="outline" onClick={()=>{
Promise.all([query.refetch(),access.refetch(),profiles.refetch()]).catch(()=>undefined);
}}>{t("retry")}</Button>}
{ready && <Card><CardContent className="divide-y pt-3">
{!filtered.length && <p className="py-4 text-muted-foreground">{t(mappings.length ? "noResults":"folders.empty")}</p>}
{filtered.map(item=><div key={item.id} className="flex flex-wrap justify-between gap-4 py-4">
<div className="min-w-0 break-words">
<h3 className="font-medium">{folder(item.values.needId).name} · {profile(item.values.profileId)}</h3>
<p className="font-mono text-sm">{item.values.driveLetter ? item.values.driveLetter+":":"—"} {item.values.path}</p>
<p className="text-sm text-muted-foreground">{t("folders.permissions")}: {folder(item.values.needId).permissions}</p>
<p className="text-sm mt-1">{t("mail."+item.values.state)}</p>
{!["removed","never_configured"].includes(item.values.state) && <p className="text-sm mt-2">
{t(item.permissionsCovered ? "folders.covered":"folders.uncovered")}</p>}
{item.needsReview && <p className="text-sm mt-1 text-amber-700">{t("folders.review")}</p>}
</div>
<div className="flex gap-2 items-start"><Button size="sm" variant="outline" onClick={()=>setView(item.id)}>{t("mail.history")}</Button>
{!["removed","never_configured"].includes(item.values.state) && <Button size="sm" variant="outline" onClick={()=>setEditor(item.id)}>{t("edit")}</Button>}</div>
</div>)}
</CardContent></Card>}
{editor && <FormDialog open wide formActions title={t(edited ? "folders.edit":"folders.new")} cancelLabel={t("cancel")}
onOpenChange={open=>{if(!open){setEditor(undefined);}}}>
<FolderMappingForm company={company} csrfToken={csrfToken} mapping={edited} initialProfileId={initialProfileId ?? ""} onSaved={()=>setEditor(undefined)} />
</FormDialog>}
{viewed && <FormDialog open wide title={t("mail.history")} cancelLabel={t("close")} onOpenChange={open=>{if(!open){setView(undefined);}}}>
<ol className="space-y-3">{[...viewed.history].reverse().map(revision=><li key={revision.id} className="rounded-md border p-3 text-sm">
<p className="font-medium">{new Intl.DateTimeFormat(i18n.language,{dateStyle:"medium",timeStyle:"short"}).format(new Date(revision.recordedAt))} · {revision.actorName}</p>
<p className="font-mono">{revision.values.driveLetter ? revision.values.driveLetter+":":"—"} {revision.values.path}</p>
<p>{t("mail."+revision.values.state)}</p>
{revision.values.notes && <p className="whitespace-pre-wrap mt-2">{revision.values.notes}</p>}
</li>)}</ol>
</FormDialog>}
</div>;
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<string>();
const [view, setView] = useState<string>();
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 (
<div className="space-y-5">
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!ready} onClick={() => setEditor("new")}>
{t("folders.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("folders.title")}
/>
{(query.isPending || access.isPending || profiles.isPending) && (
<p role="status">{t("loading")}</p>
)}
<FolderFeedback error={query.error ?? access.error ?? profiles.error} />
{(query.isError || access.isError || profiles.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), access.refetch(), profiles.refetch()]).catch(
() => undefined
);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{ready && (
<EntityTable
columns={[
{
id: "name",
label: t("folders.title"),
value: (item) => folder(item.values.needId).name,
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)}
type="button"
>
{folder(item.values.needId).name}
</button>
),
},
{
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) => (
<EntityStatus active={item.values.state === "configured"}>
{t(`mail.${item.values.state}`)}
</EntityStatus>
),
},
{
id: "review",
label: t("profiles.review"),
value: (item) => item.needsReview,
render: (item) => (
<>
{!["removed", "never_configured"].includes(item.values.state) && (
<p>{t(item.permissionsCovered ? "folders.covered" : "folders.uncovered")}</p>
)}
{item.needsReview && <p>{t("folders.review")}</p>}
</>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("folders.empty")}
items={filtered}
locale={i18n.language}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("folders.search")}
title={t("folders.title")}
/>
)}
{editor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(edited ? "folders.edit" : "folders.new")}
wide
>
<FolderMappingForm
company={company}
csrfToken={csrfToken}
initialProfileId={initialProfileId ?? ""}
mapping={edited}
onSaved={() => setEditor(undefined)}
/>
</FormDialog>
)}
{viewed && (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setView(undefined);
}
}}
open
title={t("mail.history")}
wide
>
<ol className="space-y-3">
{[...viewed.history].reverse().map((revision) => (
<li className="rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {revision.actorName}
</p>
<p className="font-mono">
{revision.values.driveLetter ? `${revision.values.driveLetter}:` : "—"}{" "}
{revision.values.path}
</p>
<p>{t(`mail.${revision.values.state}`)}</p>
{revision.values.notes && (
<p className="whitespace-pre-wrap mt-2">{revision.values.notes}</p>
)}
</li>
))}
</ol>
</FormDialog>
)}
</div>
);
}
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(" · "),
};
}

View File

@ -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 (
<div className="space-y-5">
<header className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("interventionTemplates.title")}</h2>
<Button disabled={!query.data || query.isError} onClick={() => setEditor({})}>
{t("interventionTemplates.new")}
</Button>
</header>
<p className="text-sm text-muted-foreground">
{t(
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!query.data || query.isError} onClick={() => setEditor({})}>
{t("interventionTemplates.new")}
</Button>{" "}
</>
}
description={t(
scope === "global"
? "interventionTemplates.scopeGlobal"
: "interventionTemplates.scopeCompany"
)}
</p>
<div className="flex flex-wrap gap-4 items-center">
<div>
<Label className="sr-only" htmlFor={`${prefix}-search`}>
{t("interventionTemplates.filter")}
</Label>
<Input
id={`${prefix}-search`}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("interventionTemplates.filter")}
value={search}
/>
</div>
<div className="flex gap-2 items-center">
<Checkbox
checked={showInactive}
id={`${prefix}-inactive`}
onCheckedChange={(value) => setShowInactive(value === true)}
/>
<Label htmlFor={`${prefix}-inactive`}>{t("interventionTemplates.showInactive")}</Label>
</div>
</div>
locale={i18n.language}
title={t("interventionTemplates.title")}
/>
{query.isPending && <p role="status">{t("loading")}</p>}
<InterventionTemplateFeedback error={query.error} />
{query.isError && (
@ -84,46 +74,106 @@ export function InterventionTemplatesPage({
</Button>
)}
{query.data && !query.isError && (
<>
{!visible.length && <p>{t("interventionTemplates.empty")}</p>}
{visible.map((template) => (
<section className="rounded-xl border p-5 space-y-4" key={template.id}>
<header className="flex flex-wrap justify-between gap-3">
<div>
<h3 className="font-medium">{template.values.name}</h3>
<p className="text-sm text-muted-foreground">
{t(
template.companyId
? "interventionTemplates.company"
: "interventionTemplates.global"
)}{" "}
· {t(template.values.active ? "active" : "inactive")}
</p>
</div>
<div className="flex gap-2">
{template.companyId === (scope === "global" ? null : scope) && (
<Button
onClick={() => setEditor({ id: template.id })}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (template) => 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"
)}
<Button
onClick={() => setEditor({ copyId: template.id })}
size="sm"
variant="outline"
>
{t("interventionTemplates.copy")}
</Button>
</div>
</header>
<TemplateSteps values={template.values} />
<TemplateHistory template={template} />
</section>
))}
</>
</>
),
},
{
id: "state",
label: t("status"),
value: (template) => t(template.values.active ? "active" : "inactive"),
render: (template) => (
<EntityStatus active={template.values.active}>
{t(template.values.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "steps",
label: t("interventions.steps"),
value: (template) => template.values.steps.length,
render: (template) => (
<details>
<summary className="cursor-pointer">
{t("interventions.steps")} ({template.values.steps.length})
</summary>
<TemplateSteps values={template.values} />
</details>
),
},
{
id: "history",
label: t("history"),
render: (template) => <TemplateHistory template={template} />,
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (template) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
{template.companyId === (scope === "global" ? null : scope) && (
<EntityAction onClick={() => setEditor({ id: template.id })}>
{t("edit")}
</EntityAction>
)}
<EntityAction onClick={() => setEditor({ copyId: template.id })}>
{t("interventionTemplates.copy")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("interventionTemplates.empty")}
filterKey={String(showInactive)}
filters={
<div className="flex gap-2 items-center">
<Checkbox
checked={showInactive}
id={`${prefix}-inactive`}
onCheckedChange={(value) => setShowInactive(value === true)}
/>
<Label htmlFor={`${prefix}-inactive`}>
{t("interventionTemplates.showInactive")}
</Label>
</div>
}
filtersApplied={!showInactive}
items={visible}
locale={i18n.language}
onClearFilters={() => setShowInactive(true)}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("interventionTemplates.filter")}
title={t("interventionTemplates.title")}
/>
)}
<FormDialog
cancelLabel={t("cancel")}

View File

@ -67,12 +67,14 @@ async function render(node: ReactNode, language = "es") {
client.clear();
}
}
test("plantillas de intervención: listado sin formulario, es/en, globales no editables desde compañía", async () => {
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(<InterventionTemplatesPage csrfToken="test" />);
assert.ok(global.includes("Plantillas de intervención"));
assert.equal(global.includes("<form"), false);
const scoped = await render(<InterventionTemplatesPage csrfToken="test" scope={companyId} />);
assert.equal((scoped.match(/>Editar</g) ?? []).length, 1);
assert.equal((scoped.match(/aria-label="Acciones"/g) ?? []).length, 2);
assert.ok(scoped.includes(companiesSpanish.interventionTemplates.global));
assert.ok(scoped.includes(companiesSpanish.interventionTemplates.company));
const english = await render(<InterventionTemplatesPage csrfToken="test" />, "en");
assert.ok(english.includes("Intervention templates"));
});

View File

@ -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 (
<div className="space-y-5">
<header className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">
{t(companyId ? "interventions.title" : "interventions.global")}
</h2>
<Button
disabled={!query.data || query.isError}
onClick={() => {
setEdit(undefined);
setOpen(true);
}}
>
{t("interventions.new")}
</Button>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Input
aria-label={t("interventions.search")}
onChange={(event) => setFilters({ ...filters, search: event.target.value })}
placeholder={t("interventions.search")}
value={filters.search}
/>
{!companyId && (
<SelectWithAction
id="interventions-company-filter"
label={t("tickets.company")}
onValueChange={(company) => setFilters({ ...filters, company })}
options={
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
}
placeholder={t("interventions.allCompanies")}
value={filters.company}
/>
)}
<SelectWithAction
id="interventions-state-filter"
label={t("interventions.state")}
onValueChange={(state) => setFilters({ ...filters, state })}
options={InterventionStateSchema.options.map((value) => ({
value,
label: t(`interventions.states.${value}`),
}))}
placeholder={t("interventions.allStates")}
value={filters.state}
/>
<SelectWithAction
id="interventions-assignee-filter"
label={t("interventions.assignee")}
onValueChange={(assignee) => 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}
/>
<Input
aria-label={t("interventions.dateFrom")}
onChange={(event) => setFilters({ ...filters, dateFrom: event.target.value })}
type="date"
value={filters.dateFrom}
/>
<Input
aria-label={t("interventions.dateTo")}
onChange={(event) => setFilters({ ...filters, dateTo: event.target.value })}
type="date"
value={filters.dateTo}
/>
<Button
onClick={() =>
setFilters({
search: "",
company: "",
state: "",
assignee: "",
dateFrom: "",
dateTo: "",
})
}
variant="outline"
>
{t("interventions.resetFilters")}
</Button>
</div>
<EntityListHeader
actions={
<>
{" "}
<Button
disabled={!query.data || query.isError}
onClick={() => {
setEdit(undefined);
setOpen(true);
}}
>
{t("interventions.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t(companyId ? "interventions.title" : "interventions.global")}
/>
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
{query.isError && (
<div className="space-y-3">
@ -174,60 +115,190 @@ export function InterventionsPage({
</Button>
</div>
)}
{query.data && !items.length && (
<p className="text-muted-foreground">{t("interventions.empty")}</p>
)}
<ul className="divide-y">
{items.map((intervention) => (
<li className="space-y-2 py-4" key={intervention.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">
{intervention.reference} · {intervention.companyName}
</p>
<h3 className="font-semibold">{intervention.values.subject}</h3>
</div>
<div className="flex gap-2">
<Button onClick={() => setView(intervention)} size="sm" variant="outline">
{t("interventions.details")}
</Button>
<Button
onClick={() => {
setEdit(intervention);
setOpen(true);
}}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</div>
<p className="text-sm">
{t(`interventions.states.${intervention.values.state}`)} ·{" "}
{intervention.assigneeName ?? t("interventions.unassigned")} ·{" "}
{intervention.values.minutes === null
? t("interventions.unknownTime")
: `${intervention.values.minutes} min`}
</p>
<p className="text-sm text-muted-foreground">
{intervention.ticketReference ?? t("interventions.internal")}
{intervention.values.plannedDate ? ` · ${intervention.values.plannedDate}` : ""}
</p>
{intervention.values.steps.length > 0 && (
<p className="text-sm">
{t("interventions.progress", {
done: intervention.values.steps.filter((step) => step.state === "done").length,
total: intervention.values.steps.length,
})}
</p>
<EntityTable
columns={[
{
id: "reference",
label: t("tickets.reference"),
value: (intervention) => intervention.reference,
hideable: false,
render: (intervention) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(intervention)}
type="button"
>
{intervention.reference}
</button>
),
},
{
id: "subject",
label: t("interventions.subject"),
value: (intervention) => intervention.values.subject,
hideable: false,
render: (intervention) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(intervention)}
type="button"
>
{intervention.values.subject}
</button>
),
},
{
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) => (
<EntityStatus active={!["finished", "cancelled"].includes(intervention.values.state)}>
{t(`interventions.states.${intervention.values.state}`)}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(intervention)}>
{t("interventions.details")}
</EntityAction>
<EntityAction
onClick={() => {
setEdit(intervention);
setOpen(true);
}}
>
{t("edit")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("interventions.empty")}
entityLabel={t("interventions.title")}
filterKey={JSON.stringify(filters)}
filters={
<>
{" "}
{!companyId && (
<SelectWithAction
id="interventions-company-filter"
label={t("tickets.company")}
onValueChange={(company) => setFilters({ ...filters, company })}
options={
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
}
placeholder={t("interventions.allCompanies")}
value={filters.company}
/>
)}
<p className="text-xs text-muted-foreground">
{t("tickets.updated")}: {date(intervention.history.at(-1)?.recordedAt)}
</p>
</li>
))}
</ul>
<SelectWithAction
id="interventions-state-filter"
label={t("interventions.state")}
onValueChange={(state) => setFilters({ ...filters, state })}
options={InterventionStateSchema.options.map((value) => ({
value,
label: t(`interventions.states.${value}`),
}))}
placeholder={t("interventions.allStates")}
value={filters.state}
/>
<SelectWithAction
id="interventions-assignee-filter"
label={t("interventions.assignee")}
onValueChange={(assignee) => 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}
/>
<FormDialog
cancelLabel={t("cancel")}
formActions

View File

@ -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";
@ -77,21 +82,22 @@ export function MailConfigurationsPage({
);
return (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("mail.title")}</h2>
<Button
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
onClick={() => setEditor("new")}
>
{t("mail.new")}
</Button>
</header>
<Input
aria-label={t("mail.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("mail.search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<Button
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
onClick={() => setEditor("new")}
>
{t("mail.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("mail.title")}
/>
{(query.isPending || inventory.isPending || profiles.isPending || mailboxes.isPending) && (
<p role="status">{t("loading")}</p>
)}
@ -112,40 +118,84 @@ export function MailConfigurationsPage({
</Button>
)}
{query.data && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(configurations.length ? "noResults" : "mail.empty")}
</p>
)}
{filtered.map((item) => (
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
<div className="min-w-0 break-words">
<h3 className="font-medium">
{address(item)} · {t(`mail.${item.values.method}`)}
</h3>
<p className="text-sm text-muted-foreground">{target(item.values)}</p>
<p className="mt-1 text-sm">{t(`mail.${item.values.state}`)}</p>
{item.legacy && <p className="mt-2 text-sm text-amber-700">{t("mail.legacy")}</p>}
{item.needsReview && (
<p className="mt-2 text-sm text-amber-700">{t("mail.review")}</p>
)}
</div>
<div className="flex gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("mail.history")}
</Button>
{!["removed", "never_configured"].includes(item.values.state) && (
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("mail.address"),
value: (item) => address(item),
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)}
type="button"
>
{address(item)}
</button>
),
},
{
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) => (
<EntityStatus active={item.values.state === "configured"}>
{t(`mail.${item.values.state}`)}
</EntityStatus>
),
},
{
id: "review",
label: t("profiles.review"),
value: (item) => item.needsReview,
render: (item) => (
<>
{item.legacy && <p>{t("mail.legacy")}</p>}
{item.needsReview && <p>{t("mail.review")}</p>}
{!(item.legacy || item.needsReview) && "—"}
</>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("mail.empty")}
items={filtered}
locale={i18n.language}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("mail.search")}
title={t("mail.title")}
/>
)}
{editor && (
<FormDialog

View File

@ -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";
@ -22,7 +27,7 @@ export function PrinterConfigurationsPage({
csrfToken: string;
initialProfileId?: string;
}) {
const { t } = useTranslation("companies");
const { t, i18n } = useTranslation("companies");
const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>();
const [search, setSearch] = useState("");
@ -61,18 +66,19 @@ export function PrinterConfigurationsPage({
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
return (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("printers.title")}</h2>
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
{t("printers.new")}
</Button>
</header>
<Input
aria-label={t("printers.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("printers.search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
{t("printers.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("printers.title")}
/>
{(query.isPending || profiles.isPending || inventory.isPending) && (
<p role="status">{t("loading")}</p>
)}
@ -90,40 +96,84 @@ export function PrinterConfigurationsPage({
</Button>
)}
{ready && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">{t("printers.empty")}</p>
)}
{filtered.map((item) => (
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
<div>
<h3 className="font-medium">{asset(item.values.printerId)}</h3>
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
<p className="text-sm">
{t(`printers.${item.values.connection}`)} · {t(`mail.${item.values.state}`)}
</p>
{item.values.isDefault && (
<p className="text-sm font-medium">{t("printers.default")}</p>
)}
{item.needsReview && (
<p className="text-sm text-amber-700">{t("printers.review")}</p>
)}
</div>
<div className="flex items-start gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("mail.history")}
</Button>
{!["removed", "never_configured"].includes(item.values.state) && (
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
)}
</div>
</div>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("printers.title"),
value: (item) => asset(item.values.printerId),
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)}
type="button"
>
{asset(item.values.printerId)}
</button>
),
},
{
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) => (
<EntityStatus active={item.values.state === "configured"}>
{t(`mail.${item.values.state}`)}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("printers.empty")}
items={filtered}
locale={i18n.language}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("printers.search")}
title={t("printers.title")}
/>
)}
{editor && (
<FormDialog

View File

@ -63,7 +63,7 @@ test("impresoras: listado sin formularios, alta en cabecera y traducciones", asy
assert.ok(html.includes(title ?? ""));
assert.ok(html.includes(action ?? ""));
assert.ok(!html.includes("<form"));
assert.ok(!html.includes("printers."));
assert.equal(/(?:>|")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);
}
});

View File

@ -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<string>();
const [copy, setCopy] = useState<string>();
@ -22,13 +28,20 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
};
return (
<div className="space-y-5">
<header className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("processes.templates")}</h2>
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
{t("processes.newTemplate")}
</Button>
</header>
<p className="text-sm text-muted-foreground">{t("processes.globalTemplates")}</p>
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
{t("processes.newTemplate")}
</Button>{" "}
</>
}
description={t("processes.globalTemplates")}
locale={i18n.language}
title={t("processes.templates")}
/>
{query.isPending && <p role="status">{t("loading")}</p>}
<ProcessFeedback error={query.error} />
{query.isError && (
@ -42,44 +55,56 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
</Button>
)}
{query.data && !query.isError && (
<Card>
<CardContent className="divide-y pt-3">
{templates.map((template) => (
<section className="space-y-3 py-4" key={template.id}>
<header className="flex flex-wrap justify-between gap-3">
<div>
<h3 className="font-medium">{template.values.name}</h3>
<p className="text-sm text-muted-foreground">
{t(`processes.${template.values.kind}`)} ·{" "}
{t(template.values.active ? "active" : "inactive")}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => setEditor(template.id)} size="sm" variant="outline">
{t("edit")}
</Button>
<Button
onClick={() => {
setCopy(template.id);
setEditor("new");
}}
size="sm"
variant="outline"
>
{t("processes.copy")}
</Button>
</div>
</header>
<ol className="list-decimal pl-5 text-sm">
{template.values.instructions.map((instruction) => (
<li key={instruction}>{instruction}</li>
))}
</ol>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (template) => 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) => (
<EntityStatus active={template.values.active}>
{t(template.values.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "steps",
label: t("processes.instructions"),
render: (template) => (
<details>
<summary className="cursor-pointer text-sm">{t("processes.history")}</summary>
<ol className="space-y-2 mt-2">
<summary className="cursor-pointer">
{t("processes.instructions")} ({template.values.instructions.length})
</summary>
<ol className="list-decimal pl-5">
{template.values.instructions.map((instruction) => (
<li key={instruction}>{instruction}</li>
))}
</ol>
</details>
),
},
{
id: "history",
label: t("processes.history"),
render: (template) => (
<details>
<summary className="cursor-pointer">{t("processes.history")}</summary>
<ol className="space-y-2">
{[...template.history].reverse().map((r) => (
<li className="rounded border p-2 text-sm" key={r.id}>
<li className="rounded border p-2" key={r.id}>
{r.recordedAt} · {r.actorName}
<p>
{r.values.name} · {t(r.values.active ? "active" : "inactive")}
@ -94,10 +119,34 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
))}
</ol>
</details>
</section>
))}
</CardContent>
</Card>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (template) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setEditor(template.id)}>{t("edit")}</EntityAction>
<EntityAction
onClick={() => {
setCopy(template.id);
setEditor("new");
}}
>
{t("processes.copy")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
items={templates}
locale={i18n.language}
title={t("processes.templates")}
/>
)}
<FormDialog
cancelLabel={t("cancel")}

View File

@ -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";
@ -14,7 +19,7 @@ import { ProcessFeedback } from "./process-feedback";
import { ProcessStartForm } from "./process-start-form";
import { ProcessTemplatesPage } from "./process-templates-page";
export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
const { t } = useTranslation("companies");
const { t, i18n } = useTranslation("companies");
const [templates, setTemplates] = useState(false);
const [create, setCreate] = useState(false);
const [selected, setSelected] = useState<string>();
@ -72,62 +77,112 @@ export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csr
) : (
!selected && (
<>
<header className="flex flex-wrap justify-between gap-3">
<h2 className="text-xl font-semibold">{t("processes.title")}</h2>
<div className="flex gap-2">
<Button onClick={() => setTemplates(true)} variant="outline">
{t("processes.templates")}
</Button>
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
{t("processes.new")}
</Button>
</div>
</header>
<Input
aria-label={t("processes.search")}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("processes.search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<div className="flex gap-2">
<Button onClick={() => setTemplates(true)} variant="outline">
{t("processes.templates")}
</Button>
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
{t("processes.new")}
</Button>
</div>{" "}
</>
}
locale={i18n.language}
title={t("processes.title")}
/>
<label className="flex items-center gap-2 text-sm">
<input
checked={closed}
onChange={(e) => setClosed(e.target.checked)}
type="checkbox"
/>
{t("processes.showClosed")}
</label>
{query.data && !query.isError && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(processes.length ? "noResults" : "processes.empty")}
</p>
)}
{filtered.map((p) => (
<section className="flex items-start justify-between gap-3 py-4" key={p.id}>
<div>
<h3 className="font-medium">{p.values.name}</h3>
<p>
{person(p.values.personId)} · {t(`processes.${p.values.kind}`)} ·{" "}
{t(`processes.${p.values.state}`)}
</p>
<p className="text-sm text-muted-foreground">
{t("processes.progress", {
resolved: p.values.tasks.filter((task) => task.status !== "pending")
.length,
total: p.values.tasks.length,
})}
</p>
</div>
<Button onClick={() => setSelected(p.id)} size="sm" variant="outline">
{t("processes.openDetail")}
</Button>
</section>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (p) => p.values.name,
hideable: false,
render: (p) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setSelected(p.id)}
type="button"
>
{p.values.name}
</button>
),
},
{
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) => (
<EntityStatus active={p.values.state === "open"}>
{t(`processes.${p.values.state}`)}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
{" "}
<EntityAction onClick={() => setSelected(p.id)}>
{t("processes.openDetail")}
</EntityAction>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("processes.empty")}
filterKey={String(closed)}
filters={
<label className="flex items-center gap-2 text-sm">
<input
checked={closed}
onChange={(e) => setClosed(e.target.checked)}
type="checkbox"
/>
{t("processes.showClosed")}
</label>
}
filtersApplied={closed}
items={filtered}
locale={i18n.language}
onClearFilters={() => setClosed(false)}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("processes.search")}
title={t("processes.title")}
/>
)}
</>
)

View File

@ -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 (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("profiles.title")}</h2>
<Button disabled={!ready} onClick={() => setEditor("new")}>
{t("profiles.new")}
</Button>
</header>
<Input
aria-label={t("profiles.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("profiles.search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!ready} onClick={() => setEditor("new")}>
{t("profiles.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("profiles.title")}
/>
<label className="flex items-center gap-2 text-sm">
<input
checked={archived}
onChange={(event) => setArchived(event.target.checked)}
type="checkbox"
/>
{t("profiles.showArchived")}
</label>
{(query.isPending || inventory.isPending || access.isPending) && (
<p role="status">{t("loading")}</p>
)}
@ -132,81 +130,125 @@ export function WorkProfilesPage({
</Button>
)}
{ready && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(profiles.length ? "noResults" : "profiles.empty")}
</p>
)}
{filtered.map((item) => (
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
<div className="min-w-0 break-words">
<h3 className="font-medium">
{item.values.name} ·{" "}
{t(item.values.active ? "profiles.active" : "profiles.inactive")}
</h3>
<p className="text-sm text-muted-foreground">
{person(item.values.personId)} · {asset(item.values.assetId)}
</p>
<p className="mt-1 text-sm">{account(item.values.accountId)}</p>
{item.needsReview && (
<p className="mt-2 text-sm text-amber-700">{t("profiles.review")}</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
size="sm"
>
{t("environment.title")}
</Button>
<Button
onClick={() => setConfiguration({ kind: "certificates", profileId: item.id })}
size="sm"
variant="outline"
>
{t("certificates.title")}
</Button>
<Button
onClick={() => setConfiguration({ kind: "applications", profileId: item.id })}
size="sm"
variant="outline"
>
{t("applications.title")}
</Button>
<Button
onClick={() => setConfiguration({ kind: "printers", profileId: item.id })}
size="sm"
variant="outline"
>
{t("printers.title")}
</Button>
<Button
onClick={() => setConfiguration({ kind: "folders", profileId: item.id })}
size="sm"
variant="outline"
>
{t("folders.title")}
</Button>
<Button
onClick={() => setConfiguration({ kind: "mail", profileId: item.id })}
size="sm"
variant="outline"
>
{t("mail.title")}
</Button>
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
{t("profiles.history")}
</Button>
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (item) => item.values.name,
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
type="button"
>
{item.values.name}
</button>
),
},
{
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) => (
<EntityStatus active={item.values.active}>
{t(item.values.active ? "profiles.active" : "profiles.inactive")}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
>
{t("environment.title")}
</EntityAction>
<EntityAction
onClick={() => setConfiguration({ kind: "certificates", profileId: item.id })}
>
{t("certificates.title")}
</EntityAction>
<EntityAction
onClick={() => setConfiguration({ kind: "applications", profileId: item.id })}
>
{t("applications.title")}
</EntityAction>
<EntityAction
onClick={() => setConfiguration({ kind: "printers", profileId: item.id })}
>
{t("printers.title")}
</EntityAction>
<EntityAction
onClick={() => setConfiguration({ kind: "folders", profileId: item.id })}
>
{t("folders.title")}
</EntityAction>
<EntityAction
onClick={() => setConfiguration({ kind: "mail", profileId: item.id })}
>
{t("mail.title")}
</EntityAction>
<EntityAction onClick={() => setView(item.id)}>
{t("profiles.history")}
</EntityAction>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("profiles.empty")}
filterKey={String(archived)}
filters={
<label className="flex items-center gap-2 text-sm">
<input
checked={archived}
onChange={(event) => setArchived(event.target.checked)}
type="checkbox"
/>
{t("profiles.showArchived")}
</label>
}
filtersApplied={archived}
items={filtered}
locale={i18n.language}
onClearFilters={() => setArchived(false)}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("profiles.search")}
title={t("profiles.title")}
/>
)}
{editor && (
<FormDialog

View File

@ -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";
@ -65,26 +70,19 @@ export function RemoteConnectionsPage({
const ready = Boolean(query.data && assets.data && resources.data && !error);
return (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("remote.title")}</h2>
<Button disabled={!ready} onClick={() => setEditor("new")}>
{t("remote.new")}
</Button>
</header>
<Input
aria-label={t("remote.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("remote.search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!ready} onClick={() => setEditor("new")}>
{t("remote.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("remote.title")}
/>
<label className="flex items-center gap-2 text-sm">
<input
checked={closed}
onChange={(event) => setClosed(event.target.checked)}
type="checkbox"
/>
{t("remote.showClosed")}
</label>
{[query, assets, resources].some((q) => q.isPending) && <p role="status">{t("loading")}</p>}
<RemoteFeedback error={error} />
{error && (
@ -100,41 +98,92 @@ export function RemoteConnectionsPage({
</Button>
)}
{ready && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(connections.length ? "noResults" : "remote.empty")}
</p>
)}
{filtered.map((c) => (
<div className="space-y-3 py-4" key={c.id}>
<header className="flex flex-wrap items-center justify-between gap-3">
<h3 className="font-medium">{person(c.values.personId)}</h3>
<div className="flex gap-2">
<Button onClick={() => setView(c.id)} size="sm" variant="outline">
{t("remote.history")}
</Button>
<Button
disabled={isClosed(c.values.state)}
onClick={() => setEditor(c.id)}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</header>
<EntityTable
columns={[
{
id: "person",
label: t("people"),
value: (c) => person(c.values.personId),
hideable: false,
render: (c) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(c.id)}
type="button"
>
{person(c.values.personId)}
</button>
),
},
{
id: "method",
label: t("remote.method"),
value: (c) => c.values.method,
render: (c) => <>{c.values.method}</>,
},
{
id: "details",
label: t("remote.title"),
render: (c) => (
<RemoteSummary
assets={inventory}
company={company}
connection={c}
resources={entries}
/>
</div>
))}
</CardContent>
</Card>
),
},
{
id: "state",
label: t("status"),
value: (c) => t(`mail.${c.values.state}`),
render: (c) => (
<EntityStatus active={c.values.state === "configured"}>
{t(`mail.${c.values.state}`)}
</EntityStatus>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (c) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(c.id)}>{t("remote.history")}</EntityAction>
<EntityAction
disabled={isClosed(c.values.state)}
onClick={() => setEditor(c.id)}
>
{t("edit")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("remote.empty")}
filterKey={String(closed)}
filters={
<label className="flex items-center gap-2 text-sm">
<input
checked={closed}
onChange={(event) => setClosed(event.target.checked)}
type="checkbox"
/>
{t("remote.showClosed")}
</label>
}
filtersApplied={closed}
items={filtered}
locale={i18n.language}
onClearFilters={() => setClosed(false)}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("remote.search")}
title={t("remote.title")}
/>
)}
{editor && (
<FormDialog

View File

@ -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";
@ -41,28 +46,29 @@ export function TeleworkAuthorizationsPage({
);
return (
<div className="space-y-5">
<header className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("telework.title")}</h2>
<Button
disabled={
!query.data ||
query.isError ||
Boolean(
initialPersonId &&
authorizations.some((item) => item.values.personId === initialPersonId)
)
}
onClick={() => setEditor("new")}
>
{t("telework.new")}
</Button>
</header>
<Input
aria-label={t("telework.search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("telework.search")}
value={search}
<EntityListHeader
actions={
<>
{" "}
<Button
disabled={
!query.data ||
query.isError ||
Boolean(
initialPersonId &&
authorizations.some((item) => item.values.personId === initialPersonId)
)
}
onClick={() => setEditor("new")}
>
{t("telework.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t("telework.title")}
/>
{query.isPending && <p role="status">{t("loading")}</p>}
<TeleworkFeedback error={query.error} />
{query.isError && (
@ -76,31 +82,74 @@ export function TeleworkAuthorizationsPage({
</Button>
)}
{query.data && !query.isError && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(authorizations.length && !initialPersonId ? "noResults" : "telework.empty")}
</p>
)}
{filtered.map((item) => (
<div className="space-y-3 py-4" key={item.id}>
<header className="flex flex-wrap items-center justify-between gap-3">
<h3 className="font-medium">{person(item.values.personId)}</h3>
<div className="flex gap-2">
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
<EntityTable
columns={[
{
id: "person",
label: t("people"),
value: (item) => person(item.values.personId),
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(item.id)}
type="button"
>
{person(item.values.personId)}
</button>
),
},
{
id: "state",
label: t("status"),
value: (item) => t(`telework.${item.status}`),
render: (item) => (
<EntityStatus active={item.effective}>{t(`telework.${item.status}`)}</EntityStatus>
),
},
{
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) => <TeleworkAuthorizationSummary authorization={item} />,
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("telework.history")}
</Button>
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</div>
</header>
<TeleworkAuthorizationSummary authorization={item} />
</div>
))}
</CardContent>
</Card>
</EntityAction>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
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 && (
<FormDialog

View File

@ -1,5 +1,6 @@
export const teleworkSpanish = {
title: "Teletrabajo",
authorizations: "Autorizaciones de teletrabajo",
new: "Nueva autorización",
edit: "Editar autorización de teletrabajo",
person: "Persona",
@ -48,6 +49,7 @@ export const teleworkSpanish = {
};
export const teleworkEnglish = {
title: "Telework",
authorizations: "Remote work authorizations",
new: "New authorization",
edit: "Edit telework authorization",
person: "Person",

View File

@ -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";
@ -67,74 +73,25 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
: "";
return (
<div className="space-y-5">
<header className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">
{t(companyId ? "tickets.title" : "tickets.global")}
</h2>
<Button
disabled={!query.data}
onClick={() => {
setEdit(undefined);
setOpen(true);
}}
>
{t("tickets.new")}
</Button>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Input
aria-label={t("tickets.search")}
onChange={(event) => setFilters({ ...filters, search: event.target.value })}
placeholder={t("tickets.search")}
value={filters.search}
/>
{!companyId && (
<SelectWithAction
id="tickets-company-filter"
label={t("tickets.company")}
onValueChange={(company) => setFilters({ ...filters, company })}
options={
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
}
placeholder={t("tickets.allCompanies")}
value={filters.company}
/>
)}
<SelectWithAction
id="tickets-state-filter"
label={t("tickets.state")}
onValueChange={(state) => setFilters({ ...filters, state })}
options={TicketStateSchema.options.map((value) => ({
value,
label: t(`tickets.states.${value}`),
}))}
placeholder={t("tickets.allStates")}
value={filters.state}
/>
<SelectWithAction
id="tickets-priority-filter"
label={t("tickets.priority")}
onValueChange={(priority) => setFilters({ ...filters, priority })}
options={TicketPrioritySchema.options.map((value) => ({
value,
label: t(`tickets.priorities.${value}`),
}))}
placeholder={t("tickets.allPriorities")}
value={filters.priority}
/>
<SelectWithAction
id="tickets-assignee-filter"
label={t("tickets.assignee")}
onValueChange={(assignee) => 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}
/>
</div>
<EntityListHeader
actions={
<>
{" "}
<Button
disabled={!query.data}
onClick={() => {
setEdit(undefined);
setOpen(true);
}}
>
{t("tickets.new")}
</Button>{" "}
</>
}
locale={i18n.language}
title={t(companyId ? "tickets.title" : "tickets.global")}
/>
{query.isPending && <p role="status">{t("tickets.loading")}</p>}
{query.isError && (
<div className="space-y-3">
@ -149,48 +106,173 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
</Button>
</div>
)}
{query.data && !items.length && <p className="text-muted-foreground">{t("tickets.empty")}</p>}
<ul className="divide-y">
{items.map((ticket) => (
<li className="space-y-2 py-4" key={ticket.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">
{ticket.reference} · {ticket.companyName}
</p>
<h3 className="font-semibold">{ticket.values.subject}</h3>
</div>
<div className="flex gap-2">
<Button onClick={() => setInterventionTicket(ticket)} size="sm" variant="outline">
{t("interventions.fromTicket")}
</Button>
<Button onClick={() => setView(ticket)} size="sm" variant="outline">
{t("tickets.details")}
</Button>
<Button
onClick={() => {
setEdit(ticket);
setOpen(true);
}}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</div>
<p className="text-sm">
{t(`tickets.states.${ticket.values.state}`)} ·{" "}
{t(`tickets.priorities.${ticket.values.priority}`)} ·{" "}
{ticket.requesterName ?? t("tickets.unassigned")} ·{" "}
{ticket.assigneeName ?? t("tickets.unassigned")}
</p>
<p className="text-xs text-muted-foreground">
{t("tickets.updated")}: {date(ticket.history.at(-1)?.recordedAt)}
</p>
</li>
))}
</ul>
<EntityTable
columns={[
{
id: "reference",
label: t("tickets.reference"),
value: (ticket) => ticket.reference,
hideable: false,
render: (ticket) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(ticket)}
type="button"
>
{ticket.reference}
</button>
),
},
{
id: "subject",
label: t("tickets.subject"),
value: (ticket) => ticket.values.subject,
hideable: false,
render: (ticket) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setView(ticket)}
type="button"
>
{ticket.values.subject}
</button>
),
},
{
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) => (
<EntityStatus active={!["closed", "cancelled"].includes(ticket.values.state)}>
{t(`tickets.states.${ticket.values.state}`)}
</EntityStatus>
),
},
{
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) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setInterventionTicket(ticket)}>
{t("interventions.fromTicket")}
</EntityAction>
<EntityAction onClick={() => setView(ticket)}>
{t("tickets.details")}
</EntityAction>
<EntityAction
onClick={() => {
setEdit(ticket);
setOpen(true);
}}
>
{t("edit")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("tickets.empty")}
entityLabel={t("tickets.title")}
filterKey={JSON.stringify(filters)}
filters={
<>
{" "}
{!companyId && (
<SelectWithAction
id="tickets-company-filter"
label={t("tickets.company")}
onValueChange={(company) => setFilters({ ...filters, company })}
options={
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
}
placeholder={t("tickets.allCompanies")}
value={filters.company}
/>
)}
<SelectWithAction
id="tickets-state-filter"
label={t("tickets.state")}
onValueChange={(state) => setFilters({ ...filters, state })}
options={TicketStateSchema.options.map((value) => ({
value,
label: t(`tickets.states.${value}`),
}))}
placeholder={t("tickets.allStates")}
value={filters.state}
/>
<SelectWithAction
id="tickets-priority-filter"
label={t("tickets.priority")}
onValueChange={(priority) => setFilters({ ...filters, priority })}
options={TicketPrioritySchema.options.map((value) => ({
value,
label: t(`tickets.priorities.${value}`),
}))}
placeholder={t("tickets.allPriorities")}
value={filters.priority}
/>
<SelectWithAction
id="tickets-assignee-filter"
label={t("tickets.assignee")}
onValueChange={(assignee) => 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}
/>
<FormDialog
cancelLabel={t("cancel")}
formActions

View File

@ -1,14 +1,12 @@
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 {
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 { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -21,7 +19,7 @@ import { PasswordForm } from "./password-form";
import { UserForm } from "./user-form";
export function UserManagement({ csrfToken }: { csrfToken: string }) {
const { t } = useTranslation("identity");
const { t, i18n } = useTranslation("identity");
const users = useQuery({ queryKey: identityKeys.users, queryFn: identityApi.users });
const [createOpen, setCreateOpen] = useState(false);
const [selection, setSelection] = useState<
@ -30,13 +28,17 @@ export function UserManagement({ csrfToken }: { csrfToken: string }) {
const selected: UserDTO | undefined = users.data?.users.find((user) => user.id === selection?.id);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">{t("users")}</h1>
<p className="mt-2 text-muted-foreground">{t("usersHelp")}</p>
</div>
<Button onClick={() => setCreateOpen(true)}>{t("createUser")}</Button>
</div>
<EntityListHeader
actions={
<>
{" "}
<Button onClick={() => setCreateOpen(true)}>{t("createUser")}</Button>{" "}
</>
}
description={t("usersHelp")}
locale={i18n.language}
title={t("users")}
/>
{users.isPending && <p role="status">{t("loading")}</p>}
<FormFeedback error={users.error} />
{users.isError && (
@ -50,57 +52,65 @@ export function UserManagement({ csrfToken }: { csrfToken: string }) {
</Button>
)}
{users.data && (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("email")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.data.users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<span className="font-medium">{user.name}</span>
{user.isInitialAdmin && (
<span className="mt-1 block text-xs text-muted-foreground">
{t("initialAdmin")}
</span>
)}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{t(user.active ? "active" : "inactive")}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => setSelection({ id: user.id, action: "edit" })}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
{!user.isInitialAdmin && (
<Button
onClick={() => setSelection({ id: user.id, action: "password" })}
size="sm"
variant="ghost"
>
{t("resetPassword")}
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{users.data.users.length === 0 && <p>{t("empty")}</p>}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (user) => user.name,
hideable: false,
render: (user) => (
<>
<span className="font-medium">{user.name}</span>
{user.isInitialAdmin && (
<p className="text-xs text-muted-foreground">{t("initialAdmin")}</p>
)}
</>
),
},
{
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) => (
<EntityStatus active={user.active}>
{t(user.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (user) => (
<EntityRowActions label={t("actions")}>
<div className="contents">
<EntityAction onClick={() => setSelection({ id: user.id, action: "edit" })}>
{t("edit")}
</EntityAction>
{!user.isInitialAdmin && (
<EntityAction
onClick={() => setSelection({ id: user.id, action: "password" })}
>
{t("resetPassword")}
</EntityAction>
)}
</div>
</EntityRowActions>
),
},
]}
emptyMessage={t("empty")}
items={users.data.users}
locale={i18n.language}
title={t("users")}
/>
)}
<FormDialog
cancelLabel={t("cancel")}

View File

@ -1,16 +1,12 @@
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 { 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
@ -28,8 +24,8 @@ type Editor = {
copy?: CatalogItemDTO;
};
export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
const { t } = useTranslation("resources");
const searchId = useId();
const { t, i18n } = useTranslation("resources");
const _searchId = useId();
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template">("template");
const [search, setSearch] = useState("");
@ -55,19 +51,18 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">
{t(scope === "global" ? "title" : "companyTitle")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t(scope === "global" ? "introduction" : "companyIntroduction")}
</p>
</div>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
{t({ field: "newField", template: "newTemplate" }[tab])}
</Button>
</div>
<EntityListHeader
actions={
<>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
{t({ field: "newField", template: "newTemplate" }[tab])}
</Button>
</>
}
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
locale={i18n.language}
title={t(scope === "global" ? "title" : "companyTitle")}
/>
<nav aria-label={t("title")} className="flex gap-2">
{(["template", "field"] as const).map((kind) => (
<Button
@ -96,49 +91,111 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
{t("retry")}
</Button>
)}
<Card>
<CardContent className="space-y-4 pt-6">
<div className="space-y-2">
<Label htmlFor={searchId}>{t("search")}</Label>
<Input
id={searchId}
onChange={(event) => setSearch(event.target.value)}
value={search}
/>
</div>
{query.data &&
(visible.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("origin")}</TableHead>
<TableHead>{t({ field: "type", template: "fields" }[tab])}</TableHead>
<TableHead>{t("active")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((item) => (
<CatalogRow
item={item}
items={items}
key={item.id}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
))}
</TableBody>
</Table>
) : (
<p className="text-muted-foreground">{t("empty")}</p>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (item) => item.values.name,
hideable: false,
render: (item) => (
<CatalogCell
column="name"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
{
id: "origin",
label: t("origin"),
value: (item) => t(item.builtin ? "builtin" : "custom"),
render: (item) => (
<CatalogCell
column="origin"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
{
id: "details",
label: t({ field: "type", template: "fields" }[tab]),
render: (item) => (
<CatalogCell
column="details"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
{
id: "state",
label: t("active"),
value: (item) => item.effectiveActive,
render: (item) => (
<CatalogCell
column="state"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<CatalogCell
column="actions"
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
),
},
]}
emptyMessage={t("empty")}
entityLabel={t({ field: "fields", template: "templates", relation: "relations" }[tab])}
items={visible}
key={tab}
loading={query.isPending}
locale={i18n.language}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("search")}
title={t(scope === "global" ? "title" : "companyTitle")}
unavailable={!query.data && query.isError}
/>
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
{editor && (
<FormDialog
@ -175,7 +232,8 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
</div>
);
}
function CatalogRow({
function CatalogCell({
column,
item,
scope,
items,
@ -185,6 +243,7 @@ function CatalogRow({
onView,
onStatus,
}: {
column: "name" | "origin" | "details" | "state" | "actions";
item: CatalogItemDTO;
scope: string;
items: readonly CatalogItemDTO[];
@ -197,52 +256,54 @@ function CatalogRow({
const { t } = useTranslation("resources");
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
const values = item.values;
const inactiveFields =
values.kind === "template" &&
values.fields.some(
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
);
return (
<TableRow>
<TableCell>
<p className="font-medium">{values.name}</p>
{values.description && (
<p className="text-xs text-muted-foreground">{values.description}</p>
)}
{inactiveFields && (
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
)}
</TableCell>
<TableCell>
{t(item.builtin ? "builtin" : "custom")}
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
</TableCell>
<TableCell>
{values.kind === "field"
? t(values.type)
: t("fieldCount", { count: values.fields.length })}
</TableCell>
<TableCell>
{t(item.effectiveActive ? "active" : "inactive")}
{scope !== "global" && !item.companyId && (
<p className="text-xs text-muted-foreground">
{t(item.overridden ? "overridden" : "inherited")}
</p>
)}
</TableCell>
<TableCell>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</TableCell>
</TableRow>
);
switch (column) {
case "name":
return <CatalogName item={item} items={items} onView={onView} />;
case "origin":
return (
<>
{t(item.builtin ? "builtin" : "custom")}
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
</>
);
case "details":
return (
<>
{values.kind === "field"
? t(values.type)
: t("fieldCount", { count: values.fields.length })}
</>
);
case "state":
return (
<>
<EntityStatus active={item.effectiveActive}>
{t(item.effectiveActive ? "active" : "inactive")}
</EntityStatus>
{scope !== "global" && !item.companyId && (
<p className="text-xs text-muted-foreground">
{t(item.overridden ? "overridden" : "inherited")}
</p>
)}
</>
);
case "actions":
return (
<>
<EntityRowActions label={t("actions")}>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</EntityRowActions>
</>
);
}
}
function editorTitle(editor: Editor) {
@ -271,33 +332,51 @@ function CatalogRowActions({
}) {
const { t } = useTranslation("resources");
return (
<div className="flex flex-wrap gap-2">
<Button onClick={onView} size="sm" variant="outline">
{t("view")}
</Button>
{editable && (
<Button onClick={onEdit} size="sm" variant="outline">
{t("edit")}
</Button>
)}
{item.values.kind === "template" && (
<Button onClick={onCopy} size="sm" variant="outline">
{t("copy")}
</Button>
)}
<Button
disabled={pending}
onClick={() => onStatus(!item.effectiveActive)}
size="sm"
variant="outline"
>
<div className="contents">
<EntityAction onClick={onView}>{t("view")}</EntityAction>
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>
{t(item.effectiveActive ? "deactivate" : "activate")}
</Button>
</EntityAction>
{item.overridden && (
<Button disabled={pending} onClick={() => onStatus(null)} size="sm" variant="ghost">
<EntityAction disabled={pending} onClick={() => onStatus(null)}>
{t("reset")}
</Button>
</EntityAction>
)}
</div>
);
}
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 (
<>
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={onView}
type="button"
>
{values.name}
</button>
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}
{inactiveFields && (
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
)}
</>
);
}

View File

@ -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, <ResourceCatalogPage csrfToken="test" scope="global" />);
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("<form"), false);
assert.equal(html.includes(">Editar</button>"), false);
}

View File

@ -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<string>();
const [viewId, setViewId] = useState<string>();
@ -47,13 +51,20 @@ export function ResourcesPage({
) ?? [];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<h2 className="text-2xl font-semibold">{t("inventory")}</h2>
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
{t("newResource")}
</Button>
</div>
<p className="text-sm text-muted-foreground">{t("resourceScope")}</p>
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
{t("newResource")}
</Button>{" "}
</>
}
description={t("resourceScope")}
locale={i18n.language}
title={t("inventory")}
/>
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error ?? catalog.error} />
{(query.isError || catalog.isError) && (
@ -66,46 +77,68 @@ export function ResourcesPage({
{t("retry")}
</Button>
)}
<Card>
<CardContent className="space-y-4 pt-6">
<Label htmlFor={searchId}>{t("search")}</Label>
<Input id={searchId} onChange={(event) => setSearch(event.target.value)} value={search} />
{query.data &&
(visible.length ? (
<div className="divide-y">
{visible.map((item) => (
<div
className="flex flex-wrap items-center justify-between gap-4 py-4"
key={item.id}
>
<div>
<h3 className="font-medium">{item.values.name}</h3>
<p className="text-sm text-muted-foreground">
{item.specification.templateName} ·{" "}
{t(item.values.active ? "active" : "inactive")}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => setViewId(item.id)} size="sm" variant="outline">
{t("viewResource")}
</Button>
<Button
disabled={!catalog.data}
onClick={() => setEditor(item.id)}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">{t("empty")}</p>
))}
</CardContent>
</Card>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (item) => item.values.name,
hideable: false,
render: (item) => (
<button
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setViewId(item.id)}
type="button"
>
{item.values.name}
</button>
),
},
{
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) => (
<EntityStatus active={item.values.active}>
{t(item.values.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setViewId(item.id)}>
{t("viewResource")}
</EntityAction>
<EntityAction disabled={!catalog.data} onClick={() => setEditor(item.id)}>
{t("edit")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
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 && (
<FormDialog
cancelLabel={t("cancel")}

View File

@ -12,7 +12,7 @@
"scripts": {
"ui:add": "shadcn add",
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
@ -32,6 +32,7 @@
"cn": "0.3.0",
"class-variance-authority": "0.7.1",
"@base-ui/react": "1.8.0",
"lucide-react": "1.8.0"
"lucide-react": "1.8.0",
"@tanstack/react-table": "8.21.3"
}
}

View File

@ -0,0 +1,140 @@
import { Menu } from "@base-ui/react/menu";
import type { ReactNode } from "react";
import { entityListLabels, entityListPages } from "../lib/entity-list";
import { Button } from "./button";
import { SelectWithAction } from "./select-with-action";
const ENTITY_PAGE_SIZES = [10, 25, 50, 100];
export function EntityRowActions({ children, label }: { children: ReactNode; label: string }) {
return (
<Menu.Root>
<Menu.Trigger aria-label={label} render={<Button size="sm" variant="ghost" />}>
{label} <span aria-hidden="true">···</span>
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner align="end" className="z-50" sideOffset={4}>
<Menu.Popup className="min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none">
{children}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
export function EntityAction({
children,
onClick,
disabled = false,
className,
}: {
children: ReactNode;
onClick: () => void;
disabled?: boolean;
className?: string;
}) {
return (
<Menu.Item
className={`cursor-pointer rounded-sm px-3 py-2 text-sm outline-none data-highlighted:bg-accent data-disabled:pointer-events-none data-disabled:opacity-50 ${className ?? ""}`}
disabled={disabled}
onClick={onClick}
>
{children}
</Menu.Item>
);
}
export function EntityStatus({
children,
active = true,
}: {
children: ReactNode;
active?: boolean;
}) {
return (
<span className="inline-flex items-center gap-2 text-sm">
<span
aria-hidden="true"
className={
active
? "size-2 shrink-0 rounded-full bg-primary"
: "size-2 shrink-0 rounded-full bg-muted-foreground"
}
/>
{children}
</span>
);
}
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 (
<nav aria-label={labels.page} className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground" htmlFor={`${id}-size`}>
{labels.pageSize}
</label>
<SelectWithAction
id={`${id}-size`}
label={labels.pageSize}
onValueChange={(value) => {
const size = Number(value);
if (ENTITY_PAGE_SIZES.includes(size)) {
onPageSize(size);
}
}}
options={ENTITY_PAGE_SIZES.map((size) => ({ value: String(size), label: String(size) }))}
placeholder={labels.pageSize}
value={String(pageSize)}
/>
</div>
<Button
aria-label={labels.previous}
disabled={page === 0}
onClick={() => onPage(page - 1)}
size="sm"
variant="outline"
>
‹
</Button>
{entityListPages(page, pages).map((index) => (
<Button
aria-current={index === page ? "page" : undefined}
aria-label={`${labels.page} ${index + 1}`}
key={index}
onClick={() => onPage(index)}
size="sm"
variant={index === page ? "secondary" : "ghost"}
>
{index + 1}
</Button>
))}
<Button
aria-label={labels.next}
disabled={page + 1 >= pages}
onClick={() => onPage(page + 1)}
size="sm"
variant="outline"
>
›
</Button>
</nav>
);
}

View File

@ -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 (
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-2xl font-semibold">{title}</h2>
<p className="mt-1 text-base font-medium text-muted-foreground">
{description ??
`${entityListLabels(locale).description} ${title.toLocaleLowerCase(locale)}.`}
</p>
</div>
{actions && <div className="flex flex-wrap items-center justify-end gap-2">{actions}</div>}
</header>
);
}
export type EntityColumn<T> = {
id: string;
label: string;
render: (item: T) => ReactNode;
value?: (item: T) => string | number | boolean | null | undefined;
hideable?: boolean;
};
export function EntityTable<T extends { id: string }>({
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<T>[];
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 (
<section
aria-busy={loading}
aria-label={title}
className="min-w-0 rounded-xl border bg-card text-card-foreground"
>
<div className="flex flex-wrap items-start justify-between gap-3 border-b p-4">
<div className="w-full sm:max-w-sm">
<label className="sr-only" htmlFor={`${id}-search`}>
{searchPlaceholder ?? labels.search}
</label>
<Input
id={`${id}-search`}
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
placeholder={searchPlaceholder ?? labels.placeholder}
value={currentSearch}
/>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 [&>fieldset]:min-w-40">
{filters}
{(currentSearch || filtersApplied) && (
<Button onClick={clear} size="sm" variant="outline">
{labels.clear}
</Button>
)}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<p aria-live="polite" className="text-sm text-muted-foreground">
{info}
</p>
<EntityTableConfiguration
locale={locale}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
table={table}
/>
</div>
<EntityTableContent
columns={columns}
loading={loading}
message={message}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
rowClassName={rowClassName}
table={table}
title={title}
unavailable={unavailable}
/>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3">
<p className="text-sm text-muted-foreground">{info}</p>
<EntityListPagination
id={id}
locale={locale}
onPage={(pageIndex) => setPagination({ ...pagination, pageIndex })}
onPageSize={(pageSize) => setPagination({ pageIndex: 0, pageSize })}
page={range.page}
pageSize={pagination.pageSize}
pages={range.pages}
/>
</footer>
</section>
);
}
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<typeof entityListLabels>,
emptyMessage: string | undefined
) {
if (loading) {
return labels.loading;
}
if (unavailable) {
return labels.unavailable;
}
return filtered ? labels.noResults : (emptyMessage ?? labels.empty);
}

View File

@ -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<T>({
table,
locale,
onSort,
}: {
table: TableModel<T>;
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 (
<>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => setColumnsOpen(true)} size="sm" variant="outline">
{labels.columns}
</Button>
<SelectWithAction
id={`${id}-sort`}
label={labels.sort}
onValueChange={(value) => {
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"}
/>
</div>{" "}
<FormDialog
cancelLabel={locale.startsWith("en") ? "Close" : "Cerrar"}
onOpenChange={setColumnsOpen}
open={columnsOpen}
title={labels.columns}
>
<div className="space-y-3">
{table.getAllLeafColumns().map((column) => (
<label
className="flex items-center gap-2"
htmlFor={`${id}-column-${column.id}`}
key={column.id}
>
<Checkbox
checked={column.getIsVisible()}
disabled={!column.getCanHide()}
id={`${id}-column-${column.id}`}
onCheckedChange={(checked) => column.toggleVisibility(checked === true)}
/>
{String(column.columnDef.header)}
</label>
))}
</div>
</FormDialog>
</>
);
}

View File

@ -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<T extends { id: string }>({
table,
columns,
title,
loading,
unavailable,
message,
rowClassName,
onSort,
}: {
table: TableModel<T>;
columns: readonly EntityColumn<T>[];
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 (
<Table aria-label={title}>
<TableHeader>
<TableRow>
{visibleColumns.map((column) => {
const direction = column.getIsSorted();
return (
<TableHead
aria-sort={
direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: undefined
}
className="first:min-w-40"
key={column.id}
scope="col"
>
{column.getCanSort() ? (
<button
className="inline-flex items-center gap-2 rounded py-1 text-left focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => {
column.toggleSorting();
onSort();
}}
type="button"
>
{String(column.columnDef.header)}
<span aria-hidden="true">
{direction === "asc" ? "↑" : direction === "desc" ? "↓" : "↕"}
</span>
</button>
) : (
String(column.columnDef.header)
)}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{loading || unavailable ? (
<TableRow>
<TableCell colSpan={visibleColumns.length}>
<span className="text-muted-foreground">{message}</span>
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow className={rowClassName?.(row.original)} key={row.id}>
{visibleColumns.map((column) => (
<TableCell
className="h-14 first:min-w-40 max-w-lg whitespace-normal break-words px-3 py-3"
key={column.id}
>
{columns.find((item) => item.id === column.id)?.render(row.original)}
</TableCell>
))}
</TableRow>
))
)}
{!(loading || unavailable || total) && (
<TableRow>
<TableCell className="p-6 text-muted-foreground" colSpan={visibleColumns.length}>
{message}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@ -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(
<EntityTable columns={columns} items={items} title="Personas" />
);
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(
<EntityTable columns={columns} items={items} search="Persona 21" title="Personas" />
);
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(
<EntityTable
columns={columns}
items={items.slice(0, 1)}
onSearchChange={() => 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(
<EntityTable
columns={columns}
emptyMessage="No hay personas."
items={[]}
title="Personas"
{...props}
/>
);
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(
<EntityTable columns={columns} items={[]} locale="en" title="People" />
);
assert.ok(html.includes("Showing 0-0 of 0 people"));
assert.ok(html.includes("Columns"));
assert.ok(html.includes("Items per page"));
});

View File

@ -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<T extends { id: string }>({
items,
columns,
search,
onSearchChange,
filterKey,
}: {
items: readonly T[];
columns: readonly EntityColumn<T>[];
search: string | undefined;
onSearchChange: ((value: string) => void) | undefined;
filterKey: string;
}) {
const [localSearch, setLocalSearch] = useState("");
const currentSearch = search ?? localSearch;
const [sorting, setSorting] = useState<SortingState>([]);
const [visibility, setVisibility] = useState<VisibilityState>({});
const [pagination, setPagination] = useState<PaginationState>({ 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<ColumnDef<T>[]>(
() =>
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,
};
}

View File

@ -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);
}

View File

@ -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