Repaso visual a los listados
This commit is contained in:
parent
5144073481
commit
ecfd689e44
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"editor.fontFamily": "'C64 Keyboard Regular', 'Fira Code Retina', 'Fira Code'"
|
||||||
|
}
|
||||||
@ -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.
|
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.
|
Mantener accesibilidad y estados de carga, error, vacío y acciones deshabilitadas.
|
||||||
Separar componentes por responsabilidades reales, sin fragmentar JSX trivial.
|
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.
|
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
|
## Validación y entrega
|
||||||
|
|||||||
@ -117,6 +117,8 @@ body {
|
|||||||
}
|
}
|
||||||
.asset-side-panel {
|
.asset-side-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@ -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 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.
|
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.
|
||||||
|
|||||||
111
docs/patron-de-listados-de-entidades.md
Normal file
111
docs/patron-de-listados-de-entidades.md
Normal 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.
|
||||||
@ -37,7 +37,7 @@ export function AssetSupport({
|
|||||||
<section className="space-y-3 rounded-md border p-4">
|
<section className="space-y-3 rounded-md border p-4">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 className="font-semibold">{t("support.title")}</h3>
|
<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">
|
<Button onClick={() => setToolsOpen(true)} size="sm" variant="outline">
|
||||||
{t("support.tools")}
|
{t("support.tools")}
|
||||||
</Button>
|
</Button>
|
||||||
@ -88,7 +88,7 @@ export function AssetSupport({
|
|||||||
(retired || !tools.find((tool) => tool.id === item.values.toolId)?.active) && (
|
(retired || !tools.find((tool) => tool.id === item.values.toolId)?.active) && (
|
||||||
<p className="text-sm text-amber-700">{t("support.review")}</p>
|
<p className="text-sm text-amber-700">{t("support.review")}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEdit(item);
|
setEdit(item);
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 {
|
import {
|
||||||
Table,
|
EntityAction,
|
||||||
TableBody,
|
EntityListHeader,
|
||||||
TableCell,
|
EntityRowActions,
|
||||||
TableHead,
|
EntityStatus,
|
||||||
TableHeader,
|
EntityTable,
|
||||||
TableRow,
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
} from "@repo/shadcn-ui/components/table";
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -29,8 +25,8 @@ type Editor = {
|
|||||||
copy?: CatalogItemDTO;
|
copy?: CatalogItemDTO;
|
||||||
};
|
};
|
||||||
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
||||||
const { t } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
const searchId = useId();
|
const _searchId = useId();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
|
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@ -56,19 +52,18 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<EntityListHeader
|
||||||
<div>
|
actions={
|
||||||
<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 })}>
|
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||||
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
|
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||||
|
locale={i18n.language}
|
||||||
|
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||||
|
/>
|
||||||
<nav aria-label={t("title")} className="flex gap-2">
|
<nav aria-label={t("title")} className="flex gap-2">
|
||||||
{(["template", "field", "relation"] as const).map((kind) => (
|
{(["template", "field", "relation"] as const).map((kind) => (
|
||||||
<Button
|
<Button
|
||||||
@ -97,36 +92,18 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
{t("retry")}
|
{t("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="space-y-4 pt-6">
|
columns={[
|
||||||
<div className="space-y-2">
|
{
|
||||||
<Label htmlFor={searchId}>{t("search")}</Label>
|
id: "name",
|
||||||
<Input
|
label: t("name"),
|
||||||
id={searchId}
|
value: (item) => item.values.name,
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
hideable: false,
|
||||||
value={search}
|
render: (item) => (
|
||||||
/>
|
<CatalogCell
|
||||||
</div>
|
column="name"
|
||||||
{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}
|
item={item}
|
||||||
items={items}
|
items={items}
|
||||||
key={item.id}
|
|
||||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||||
onEdit={() => setEditor({ kind: tab, item })}
|
onEdit={() => setEditor({ kind: tab, item })}
|
||||||
onStatus={(active) => mutation.mutate({ item, active })}
|
onStatus={(active) => mutation.mutate({ item, active })}
|
||||||
@ -134,14 +111,92 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
pending={mutation.isPending}
|
pending={mutation.isPending}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
/>
|
/>
|
||||||
))}
|
),
|
||||||
</TableBody>
|
},
|
||||||
</Table>
|
{
|
||||||
) : (
|
id: "origin",
|
||||||
<p className="text-muted-foreground">{t("empty")}</p>
|
label: t("origin"),
|
||||||
))}
|
value: (item) => t(item.builtin ? "builtin" : "custom"),
|
||||||
</CardContent>
|
render: (item) => (
|
||||||
</Card>
|
<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)} />
|
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
|
||||||
{editor && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -185,7 +240,8 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function CatalogRow({
|
function CatalogCell({
|
||||||
|
column,
|
||||||
item,
|
item,
|
||||||
scope,
|
scope,
|
||||||
items,
|
items,
|
||||||
@ -195,6 +251,7 @@ function CatalogRow({
|
|||||||
onView,
|
onView,
|
||||||
onStatus,
|
onStatus,
|
||||||
}: {
|
}: {
|
||||||
|
column: "name" | "origin" | "details" | "state" | "actions";
|
||||||
item: CatalogItemDTO;
|
item: CatalogItemDTO;
|
||||||
scope: string;
|
scope: string;
|
||||||
items: readonly CatalogItemDTO[];
|
items: readonly CatalogItemDTO[];
|
||||||
@ -206,43 +263,36 @@ function CatalogRow({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
|
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
|
||||||
const values = item.values;
|
const _values = item.values;
|
||||||
const inactiveFields =
|
switch (column) {
|
||||||
values.kind === "template" &&
|
case "name":
|
||||||
values.fields.some(
|
return <CatalogName item={item} items={items} onView={onView} />;
|
||||||
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
|
case "origin":
|
||||||
);
|
|
||||||
return (
|
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")}
|
{t(item.builtin ? "builtin" : "custom")}
|
||||||
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
|
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>
|
);
|
||||||
{values.kind === "field"
|
case "details":
|
||||||
? t(values.type)
|
return <CatalogDetails item={item} />;
|
||||||
: values.kind === "relation"
|
case "state":
|
||||||
? values.inverseName
|
return (
|
||||||
: t("fieldCount", { count: values.fields.length })}
|
<>
|
||||||
</TableCell>
|
<EntityStatus active={item.effectiveActive}>
|
||||||
<TableCell>
|
|
||||||
{t(item.effectiveActive ? "active" : "inactive")}
|
{t(item.effectiveActive ? "active" : "inactive")}
|
||||||
|
</EntityStatus>
|
||||||
{scope !== "global" && !item.companyId && (
|
{scope !== "global" && !item.companyId && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(item.overridden ? "overridden" : "inherited")}
|
{t(item.overridden ? "overridden" : "inherited")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>
|
);
|
||||||
|
case "actions":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EntityRowActions label={t("actions")}>
|
||||||
<CatalogRowActions
|
<CatalogRowActions
|
||||||
editable={editable}
|
editable={editable}
|
||||||
item={item}
|
item={item}
|
||||||
@ -252,10 +302,11 @@ function CatalogRow({
|
|||||||
onView={onView}
|
onView={onView}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</EntityRowActions>
|
||||||
</TableRow>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function editorTitle(editor: Editor) {
|
function editorTitle(editor: Editor) {
|
||||||
if (editor.kind === "field") {
|
if (editor.kind === "field") {
|
||||||
@ -286,33 +337,63 @@ function CatalogRowActions({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="contents">
|
||||||
<Button onClick={onView} size="sm" variant="outline">
|
<EntityAction onClick={onView}>{t("view")}</EntityAction>
|
||||||
{t("view")}
|
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
|
||||||
</Button>
|
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
|
||||||
{editable && (
|
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{t(item.effectiveActive ? "deactivate" : "activate")}
|
{t(item.effectiveActive ? "deactivate" : "activate")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
{item.overridden && (
|
{item.overridden && (
|
||||||
<Button disabled={pending} onClick={() => onStatus(null)} size="sm" variant="ghost">
|
<EntityAction disabled={pending} onClick={() => onStatus(null)}>
|
||||||
{t("reset")}
|
{t("reset")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 });
|
||||||
|
}
|
||||||
|
|||||||
@ -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 () => {
|
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 [
|
for (const [language, create, actions] of [
|
||||||
["es", "Nueva plantilla", "Crear copia"],
|
["es", "Nueva plantilla", "Acciones"],
|
||||||
["en", "New template", "Create copy"],
|
["en", "New template", "Actions"],
|
||||||
]) {
|
]) {
|
||||||
assert.ok(language && create && copy);
|
assert.ok(language && create && actions);
|
||||||
const html = await render(language, <AssetCatalogPage csrfToken="test" scope="global" />);
|
const html = await render(language, <AssetCatalogPage csrfToken="test" scope="global" />);
|
||||||
assert.ok(html.includes("Portátil"));
|
assert.ok(html.includes("Portátil"));
|
||||||
assert.ok(html.includes(create));
|
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("<form"), false);
|
||||||
assert.equal(html.includes(">Editar</button>"), false);
|
assert.equal(html.includes(">Editar</button>"), false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -25,7 +30,7 @@ export function AssetInventoryPage({
|
|||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
lookups: InventoryLookups;
|
lookups: InventoryLookups;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: inventoryKeys.company(companyId),
|
queryKey: inventoryKeys.company(companyId),
|
||||||
queryFn: () => inventoryApi.get(companyId),
|
queryFn: () => inventoryApi.get(companyId),
|
||||||
@ -57,8 +62,10 @@ export function AssetInventoryPage({
|
|||||||
return (
|
return (
|
||||||
<div className="inventory-layout" data-detail-open={detailOpen && Boolean(selected)}>
|
<div className="inventory-layout" data-detail-open={detailOpen && Boolean(selected)}>
|
||||||
<div className="inventory-list space-y-5">
|
<div className="inventory-list space-y-5">
|
||||||
<header className="flex items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("inventory")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{!detailOpen && (
|
{!detailOpen && (
|
||||||
<Button
|
<Button
|
||||||
@ -72,14 +79,13 @@ export function AssetInventoryPage({
|
|||||||
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
|
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
|
||||||
{t("newAsset")}
|
{t("newAsset")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("search")}
|
locale={i18n.language}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
title={t("inventory")}
|
||||||
placeholder={t("search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
|
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
|
||||||
<InventoryFeedback error={query.error} />
|
<InventoryFeedback error={query.error} />
|
||||||
<CatalogError error={catalog.error} />
|
<CatalogError error={catalog.error} />
|
||||||
@ -94,23 +100,17 @@ export function AssetInventoryPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{query.data && (
|
{query.data && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{!filtered.length && (
|
{
|
||||||
<p className="py-4 text-muted-foreground">
|
id: "name",
|
||||||
{t(assets.length ? "noResults" : "noAssets")}
|
label: t("name"),
|
||||||
</p>
|
value: (asset) => asset.values.name,
|
||||||
)}
|
hideable: false,
|
||||||
{filtered.map((asset) => (
|
render: (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
|
<button
|
||||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
aria-pressed={selected?.id === asset.id && detailOpen}
|
||||||
className="inventory-asset-name"
|
className="inventory-asset-name text-left font-medium"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedId(asset.id);
|
setSelectedId(asset.id);
|
||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
@ -122,40 +122,81 @@ export function AssetInventoryPage({
|
|||||||
>
|
>
|
||||||
{asset.values.name}
|
{asset.values.name}
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
),
|
||||||
<p className="text-sm text-muted-foreground">
|
},
|
||||||
{asset.specification.templateName} · {t(asset.values.status)}
|
{
|
||||||
</p>
|
id: "template",
|
||||||
<p className="text-sm text-muted-foreground">
|
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 ??
|
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||||
t("noAssignment")}{" "}
|
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 ??
|
{lookups.people.find((person) => person.id === asset.values.personId)?.name ??
|
||||||
t("noAssignment")}
|
t("noAssignment")}
|
||||||
</p>
|
</>
|
||||||
</div>
|
),
|
||||||
<div className="flex gap-2">
|
},
|
||||||
<Button
|
{
|
||||||
|
id: "actions",
|
||||||
|
label: t("actions"),
|
||||||
|
hideable: false,
|
||||||
|
render: (asset) => (
|
||||||
|
<EntityRowActions label={t("actions")}>
|
||||||
|
{" "}
|
||||||
|
<div className="contents">
|
||||||
|
<EntityAction
|
||||||
className="asset-mobile-action"
|
className="asset-mobile-action"
|
||||||
onClick={() => setViewId(asset.id)}
|
onClick={() => setViewId(asset.id)}
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
{t("viewAsset")}
|
{t("viewAsset")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button
|
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
|
||||||
disabled={!catalog.data}
|
|
||||||
onClick={() => setEditor(asset.id)}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
</div>
|
</div>{" "}
|
||||||
</div>
|
</EntityRowActions>
|
||||||
))}
|
),
|
||||||
</CardContent>
|
},
|
||||||
</Card>
|
]}
|
||||||
|
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>
|
</div>
|
||||||
<AssetSelectionPanel
|
<AssetSelectionPanel
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@ -9,13 +16,16 @@ import type { SupportToolDTO } from "../common/support";
|
|||||||
import { supportApi, supportKeys } from "./support-api";
|
import { supportApi, supportKeys } from "./support-api";
|
||||||
import { SupportToolForm } from "./support-tool-form";
|
import { SupportToolForm } from "./support-tool-form";
|
||||||
export function SupportTools({ csrfToken }: { csrfToken: string }) {
|
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 query = useQuery({ queryKey: supportKeys.tools, queryFn: () => supportApi.tools() });
|
||||||
const [edit, setEdit] = useState<SupportToolDTO | undefined>();
|
const [edit, setEdit] = useState<SupportToolDTO | undefined>();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<header className="flex justify-end">
|
<EntityListHeader
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEdit(undefined);
|
setEdit(undefined);
|
||||||
@ -23,8 +33,12 @@ export function SupportTools({ csrfToken }: { csrfToken: string }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("support.newTool")}
|
{t("support.newTool")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
|
}
|
||||||
|
locale={i18n.language}
|
||||||
|
title={t("support.tools")}
|
||||||
|
/>
|
||||||
{query.isPending && <p role="status">{t("support.loading")}</p>}
|
{query.isPending && <p role="status">{t("support.loading")}</p>}
|
||||||
{query.isError && (
|
{query.isError && (
|
||||||
<div role="alert">
|
<div role="alert">
|
||||||
@ -39,25 +53,55 @@ export function SupportTools({ csrfToken }: { csrfToken: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="divide-y">
|
<EntityTable
|
||||||
{query.data?.tools.map((tool) => (
|
columns={[
|
||||||
<li className="flex items-center justify-between gap-3 py-3" key={tool.id}>
|
{
|
||||||
<span>
|
id: "name",
|
||||||
{tool.name} · {t(tool.active ? "support.active" : "inactive")}
|
label: t("name"),
|
||||||
{tool.builtin ? ` · ${t("support.builtin")}` : ""}
|
value: (tool) => tool.name,
|
||||||
</span>
|
hideable: false,
|
||||||
<Button
|
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={() => {
|
onClick={() => {
|
||||||
setEdit(tool);
|
setEdit(tool);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
</li>
|
</EntityRowActions>
|
||||||
))}
|
),
|
||||||
</ul>
|
},
|
||||||
|
]}
|
||||||
|
items={query.data?.tools ?? []}
|
||||||
|
loading={query.isPending}
|
||||||
|
locale={i18n.language}
|
||||||
|
title={t("support.tools")}
|
||||||
|
unavailable={query.isError}
|
||||||
|
/>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
formActions
|
formActions
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -44,7 +49,7 @@ export function WorkstationsPage({
|
|||||||
(value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
(value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const summary = (item: WorkstationDTO) => (
|
const _summary = (item: WorkstationDTO) => (
|
||||||
<>
|
<>
|
||||||
<h3 className="font-medium">
|
<h3 className="font-medium">
|
||||||
{item.values.name} · {t(item.values.active ? "active" : "inactive")}
|
{item.values.name} · {t(item.values.active ? "active" : "inactive")}
|
||||||
@ -66,18 +71,19 @@ export function WorkstationsPage({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("workstations")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button disabled={!query.data} onClick={() => setEditor("new")}>
|
<Button disabled={!query.data} onClick={() => setEditor("new")}>
|
||||||
{t("newWorkstation")}
|
{t("newWorkstation")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("search")}
|
locale={i18n.language}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
title={t("workstations")}
|
||||||
placeholder={t("search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||||
<InventoryFeedback error={query.error} />
|
<InventoryFeedback error={query.error} />
|
||||||
{query.isError && (
|
{query.isError && (
|
||||||
@ -91,28 +97,94 @@ export function WorkstationsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{query.data && (
|
{query.data && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{!filtered.length && (
|
{
|
||||||
<p className="py-4 text-muted-foreground">
|
id: "name",
|
||||||
{t(stations.length ? "noResults" : "noWorkstations")}
|
label: t("name"),
|
||||||
</p>
|
value: (item) => item.values.name,
|
||||||
)}
|
hideable: false,
|
||||||
{filtered.map((item) => (
|
render: (item) => (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
|
<button
|
||||||
<div className="min-w-0 break-words">{summary(item)}</div>
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
<div className="flex gap-2">
|
onClick={() => setView(item.id)}
|
||||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
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")}
|
{t("workstationHistory")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||||
{t("edit")}
|
</div>{" "}
|
||||||
</Button>
|
</EntityRowActions>
|
||||||
</div>
|
),
|
||||||
</div>
|
},
|
||||||
))}
|
]}
|
||||||
</CardContent>
|
emptyMessage={t("noWorkstations")}
|
||||||
</Card>
|
items={filtered}
|
||||||
|
locale={i18n.language}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
search={search}
|
||||||
|
searchPlaceholder={t("search")}
|
||||||
|
title={t("workstations")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{editor && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import {
|
||||||
import { TableCell, TableRow } from "@repo/shadcn-ui/components/table";
|
EntityAction,
|
||||||
|
EntityRowActions,
|
||||||
|
EntityStatus,
|
||||||
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { AccessFieldsDTO, AccessItemDTO, AccessResponseDTO, PersonDTO } from "../../common";
|
import type { AccessFieldsDTO, AccessItemDTO, AccessResponseDTO, PersonDTO } from "../../common";
|
||||||
@ -17,7 +20,11 @@ function EntryState({ value }: { value: AccessFieldsDTO }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p>{state}</p>
|
<EntityStatus
|
||||||
|
active={"active" in value ? value.active : "endDate" in value && !value.endDate}
|
||||||
|
>
|
||||||
|
{state}
|
||||||
|
</EntityStatus>
|
||||||
{"configuration" in value && value.configuration && (
|
{"configuration" in value && value.configuration && (
|
||||||
<p className="text-sm">{t(value.configuration)}</p>
|
<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,
|
item,
|
||||||
data,
|
data,
|
||||||
scope,
|
scope,
|
||||||
@ -43,6 +51,7 @@ export function AccessRow({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onHistory,
|
onHistory,
|
||||||
}: {
|
}: {
|
||||||
|
column: "name" | "state" | "review" | "actions";
|
||||||
item: AccessItemDTO;
|
item: AccessItemDTO;
|
||||||
data: AccessResponseDTO;
|
data: AccessResponseDTO;
|
||||||
scope: string;
|
scope: string;
|
||||||
@ -60,40 +69,55 @@ export function AccessRow({
|
|||||||
return permission?.kind === "permission" ? permission.name : "—";
|
return permission?.kind === "permission" ? permission.name : "—";
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
switch (column) {
|
||||||
|
case "name":
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<>
|
||||||
<TableCell>
|
{" "}
|
||||||
<p className="font-medium">{accessLabel(item, data.items, people)}</p>
|
<p className="font-medium">{accessLabel(item, data.items, people)}</p>
|
||||||
{!own && <p className="text-xs text-muted-foreground">{t("global")}</p>}
|
{!own && <p className="text-xs text-muted-foreground">{t("global")}</p>}{" "}
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>
|
);
|
||||||
|
case "state":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<EntryState value={item.values} />
|
<EntryState value={item.values} />
|
||||||
{coverage && <p className="text-sm">{t(coverage.covered ? "covered" : "notCovered")}</p>}
|
{coverage && <p className="text-sm">{t(coverage.covered ? "covered" : "notCovered")}</p>}
|
||||||
{coverage && !coverage.covered && missing && (
|
{coverage && !coverage.covered && missing && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("missingPermissions")}: {missing}
|
{t("missingPermissions")}: {missing}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}{" "}
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>
|
);
|
||||||
|
case "review":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
{reviews.length
|
{reviews.length
|
||||||
? reviews.map((review) => (
|
? reviews.map((review) => (
|
||||||
<p className="text-sm" key={review.reason}>
|
<p className="text-sm" key={review.reason}>
|
||||||
{t(`reviews.${review.reason}`)}
|
{t(`reviews.${review.reason}`)}
|
||||||
</p>
|
</p>
|
||||||
))
|
))
|
||||||
: "—"}
|
: "—"}{" "}
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>
|
);
|
||||||
<div className="flex gap-2">
|
case "actions":
|
||||||
<Button disabled={!own} onClick={onEdit} size="sm" variant="outline">
|
return (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<EntityRowActions label={t("actions")}>
|
||||||
|
{" "}
|
||||||
|
<div className="contents">
|
||||||
|
<EntityAction disabled={!own} onClick={onEdit}>
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button onClick={onHistory} size="sm" variant="ghost">
|
<EntityAction onClick={onHistory}>{t("history")}</EntityAction>
|
||||||
{t("history")}
|
</div>{" "}
|
||||||
</Button>
|
</EntityRowActions>{" "}
|
||||||
</div>
|
</>
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,15 +1,6 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { 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 { useQuery } from "@tanstack/react-query";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -21,7 +12,7 @@ import { accessFields, createLabels } from "./access-fields";
|
|||||||
import { AccessForm } from "./access-form";
|
import { AccessForm } from "./access-form";
|
||||||
import { AccessHistory } from "./access-history";
|
import { AccessHistory } from "./access-history";
|
||||||
import { accessLabel } from "./access-labels";
|
import { accessLabel } from "./access-labels";
|
||||||
import { AccessRow } from "./access-row";
|
import { AccessCell } from "./access-row";
|
||||||
import { EntityHelp } from "./entity-help";
|
import { EntityHelp } from "./entity-help";
|
||||||
|
|
||||||
export function AccessWorkspacePage({
|
export function AccessWorkspacePage({
|
||||||
@ -33,7 +24,7 @@ export function AccessWorkspacePage({
|
|||||||
people?: readonly PersonDTO[];
|
people?: readonly PersonDTO[];
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("access");
|
const { t, i18n } = useTranslation("access");
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: accessKeys.scope(scope),
|
queryKey: accessKeys.scope(scope),
|
||||||
queryFn: () => accessApi.request(scope),
|
queryFn: () => accessApi.request(scope),
|
||||||
@ -43,7 +34,7 @@ export function AccessWorkspacePage({
|
|||||||
const [history, setHistory] = useState<string>();
|
const [history, setHistory] = useState<string>();
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const searchId = useId();
|
const _searchId = useId();
|
||||||
const data = query.data;
|
const data = query.data;
|
||||||
const selected = data?.items.find((item) => item.id === editor);
|
const selected = data?.items.find((item) => item.id === editor);
|
||||||
const historical = data?.items.find((item) => item.id === history);
|
const historical = data?.items.find((item) => item.id === history);
|
||||||
@ -63,18 +54,21 @@ export function AccessWorkspacePage({
|
|||||||
"resource_type",
|
"resource_type",
|
||||||
"permission",
|
"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;
|
const complex = accessFields[kind].length > 5;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<div>
|
actions={
|
||||||
<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">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => setHelpOpen(true)} variant="outline">
|
<Button onClick={() => setHelpOpen(true)} variant="outline">
|
||||||
{t("helpButton")}
|
{t("helpButton")}
|
||||||
@ -82,8 +76,13 @@ export function AccessWorkspacePage({
|
|||||||
<Button disabled={!data} onClick={() => setEditor("new")}>
|
<Button disabled={!data} onClick={() => setEditor("new")}>
|
||||||
{t(createLabels[kind])}
|
{t(createLabels[kind])}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>{" "}
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
description={t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
|
||||||
|
locale={i18n.language}
|
||||||
|
title={t(`kinds.${kind}`)}
|
||||||
|
/>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("helpClose")}
|
cancelLabel={t("helpClose")}
|
||||||
onOpenChange={setHelpOpen}
|
onOpenChange={setHelpOpen}
|
||||||
@ -125,52 +124,81 @@ export function AccessWorkspacePage({
|
|||||||
)}
|
)}
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-sm space-y-2">
|
<EntityTable
|
||||||
<Label htmlFor={searchId}>{t("search")}</Label>
|
columns={[
|
||||||
<Input
|
{
|
||||||
id={searchId}
|
id: "name",
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
label: t("name"),
|
||||||
value={search}
|
value: (item) => accessLabel(item, data.items, people),
|
||||||
/>
|
hideable: false,
|
||||||
</div>
|
render: (item) => (
|
||||||
<Card>
|
<AccessCell
|
||||||
<CardContent className="pt-6">
|
column="name"
|
||||||
<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}
|
data={data}
|
||||||
item={item}
|
item={item}
|
||||||
key={item.id}
|
|
||||||
onEdit={() => setEditor(item.id)}
|
onEdit={() => setEditor(item.id)}
|
||||||
onHistory={() => setHistory(item.id)}
|
onHistory={() => setHistory(item.id)}
|
||||||
people={people}
|
people={people}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
/>
|
/>
|
||||||
))}
|
),
|
||||||
</TableBody>
|
},
|
||||||
</Table>
|
{
|
||||||
{!data.items.some((item) => item.values.kind === kind) && (
|
id: "state",
|
||||||
<p className="py-4 text-muted-foreground">{t("empty")}</p>
|
label: t("state"),
|
||||||
)}
|
render: (item) => (
|
||||||
</CardContent>
|
<AccessCell
|
||||||
</Card>
|
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
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
formActions
|
formActions
|
||||||
|
|||||||
@ -4,9 +4,14 @@ import {
|
|||||||
inventoryKeys as resourceKeys,
|
inventoryKeys as resourceKeys,
|
||||||
} from "@metis/resources/client";
|
} from "@metis/resources/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -26,7 +31,7 @@ export function ApplicationConfigurationsPage({
|
|||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
initialProfileId?: string;
|
initialProfileId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const [editor, setEditor] = useState<string>();
|
const [editor, setEditor] = useState<string>();
|
||||||
const [view, setView] = useState<string>();
|
const [view, setView] = useState<string>();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@ -71,18 +76,19 @@ export function ApplicationConfigurationsPage({
|
|||||||
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
|
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("applications.title")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||||
{t("applications.new")}
|
{t("applications.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("applications.search")}
|
locale={i18n.language}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
title={t("applications.title")}
|
||||||
placeholder={t("applications.search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
|
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
|
||||||
<p role="status">{t("loading")}</p>
|
<p role="status">{t("loading")}</p>
|
||||||
)}
|
)}
|
||||||
@ -105,42 +111,84 @@ export function ApplicationConfigurationsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{ready && (
|
{ready && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{!filtered.length && (
|
{
|
||||||
<p className="py-4 text-muted-foreground">{t("applications.empty")}</p>
|
id: "name",
|
||||||
)}
|
label: t("applications.title"),
|
||||||
{filtered.map((item) => (
|
value: (item) => resource(item.values.resourceId),
|
||||||
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
|
hideable: false,
|
||||||
<div>
|
render: (item) => (
|
||||||
<h3 className="font-medium">{resource(item.values.resourceId)}</h3>
|
<button
|
||||||
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
<p className="text-sm">
|
onClick={() => setView(item.id)}
|
||||||
{t(`applications.${item.values.delivery}`)} · {t(`mail.${item.values.state}`)}
|
type="button"
|
||||||
</p>
|
>
|
||||||
{item.values.version && (
|
{resource(item.values.resourceId)}
|
||||||
<p className="text-sm">
|
</button>
|
||||||
{t("applications.versionValue", { version: item.values.version })}
|
),
|
||||||
</p>
|
},
|
||||||
)}
|
{
|
||||||
{item.needsReview && (
|
id: "profile",
|
||||||
<p className="text-sm text-amber-700">{t("applications.review")}</p>
|
label: t("profiles.title"),
|
||||||
)}
|
value: (item) => label(item.values.profileId),
|
||||||
</div>
|
render: (item) => <>{label(item.values.profileId)}</>,
|
||||||
<div className="flex items-start gap-2">
|
},
|
||||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
{
|
||||||
|
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")}
|
{t("mail.history")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||||
{t("edit")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>{" "}
|
||||||
</div>
|
</EntityRowActions>
|
||||||
))}
|
),
|
||||||
</CardContent>
|
},
|
||||||
</Card>
|
]}
|
||||||
|
emptyMessage={t("applications.empty")}
|
||||||
|
items={filtered}
|
||||||
|
locale={i18n.language}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
search={search}
|
||||||
|
searchPlaceholder={t("applications.search")}
|
||||||
|
title={t("applications.title")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{editor && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
|
|||||||
@ -66,7 +66,7 @@ test("aplicaciones: listado sin formularios, alta en cabecera y traducciones", a
|
|||||||
assert.ok(html.includes(title ?? ""));
|
assert.ok(html.includes(title ?? ""));
|
||||||
assert.ok(html.includes(action ?? ""));
|
assert.ok(html.includes(action ?? ""));
|
||||||
assert.ok(!html.includes("<form"));
|
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 () => {
|
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.includes(text.applications.newResource));
|
||||||
assert.ok(html.indexOf(text.applications.help) > html.indexOf(text.applications.delivery));
|
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.indexOf(text.save) > html.indexOf(text.applications.help));
|
||||||
assert.ok(!html.includes("applications."));
|
assert.equal(/(?:>|")applications\./.test(html), false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,9 +4,14 @@ import {
|
|||||||
inventoryKeys as resourceKeys,
|
inventoryKeys as resourceKeys,
|
||||||
} from "@metis/resources/client";
|
} from "@metis/resources/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -71,18 +76,19 @@ export function CertificateConfigurationsPage({
|
|||||||
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
|
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("certificates.title")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||||
{t("certificates.new")}
|
{t("certificates.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("certificates.search")}
|
locale={i18n.language}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
title={t("certificates.title")}
|
||||||
placeholder={t("certificates.search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
|
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
|
||||||
<p role="status">{t("loading")}</p>
|
<p role="status">{t("loading")}</p>
|
||||||
)}
|
)}
|
||||||
@ -105,51 +111,91 @@ export function CertificateConfigurationsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{ready && (
|
{ready && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{!filtered.length && (
|
{
|
||||||
<p className="py-4 text-muted-foreground">{t("certificates.empty")}</p>
|
id: "name",
|
||||||
)}
|
label: t("certificates.title"),
|
||||||
{filtered.map((item) => (
|
value: (item) => resource(item.values.resourceId),
|
||||||
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
|
hideable: false,
|
||||||
<div>
|
render: (item) => (
|
||||||
<h3 className="font-medium">{resource(item.values.resourceId)}</h3>
|
<button
|
||||||
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
<p className="text-sm">{t(`mail.${item.values.state}`)}</p>
|
onClick={() => setView(item.id)}
|
||||||
{item.values.location && (
|
type="button"
|
||||||
<p className="text-sm">
|
>
|
||||||
{t("certificates.locationValue", { location: item.values.location })}
|
{resource(item.values.resourceId)}
|
||||||
</p>
|
</button>
|
||||||
)}
|
),
|
||||||
{item.expiresOn && (
|
},
|
||||||
<p className="text-sm">
|
{
|
||||||
{t("certificates.expiryValue", {
|
id: "profile",
|
||||||
date: new Intl.DateTimeFormat(i18n.language, {
|
label: t("profiles.title"),
|
||||||
dateStyle: "medium",
|
value: (item) => label(item.values.profileId),
|
||||||
timeZone: "UTC",
|
render: (item) => <>{label(item.values.profileId)}</>,
|
||||||
}).format(new Date(item.expiresOn)),
|
},
|
||||||
})}
|
{
|
||||||
|
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")}` : ""}
|
{item.expired ? ` · ${t("certificates.expired")}` : ""}
|
||||||
</p>
|
</>
|
||||||
)}
|
),
|
||||||
{item.needsReview && (
|
},
|
||||||
<p className="text-sm text-amber-700">{t("certificates.review")}</p>
|
{
|
||||||
)}
|
id: "review",
|
||||||
</div>
|
label: t("profiles.review"),
|
||||||
<div className="flex items-start gap-2">
|
value: (item) => item.needsReview,
|
||||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
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")}
|
{t("mail.history")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||||
{t("edit")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>{" "}
|
||||||
</div>
|
</EntityRowActions>
|
||||||
))}
|
),
|
||||||
</CardContent>
|
},
|
||||||
</Card>
|
]}
|
||||||
|
emptyMessage={t("certificates.empty")}
|
||||||
|
items={filtered}
|
||||||
|
locale={i18n.language}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
search={search}
|
||||||
|
searchPlaceholder={t("certificates.search")}
|
||||||
|
title={t("certificates.title")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{editor && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 {
|
import {
|
||||||
Table,
|
EntityAction,
|
||||||
TableBody,
|
EntityListHeader,
|
||||||
TableCell,
|
EntityRowActions,
|
||||||
TableHead,
|
EntityStatus,
|
||||||
TableHeader,
|
EntityTable,
|
||||||
TableRow,
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
} from "@repo/shadcn-ui/components/table";
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
|
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
|
||||||
@ -37,12 +33,12 @@ export function CompaniesPage({
|
|||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
onSelectCompany?: (id: string) => void;
|
onSelectCompany?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
|
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const id = useId();
|
const _id = useId();
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: query.data?.companies ?? EMPTY_COMPANIES,
|
data: query.data?.companies ?? EMPTY_COMPANIES,
|
||||||
columns: COMPANY_COLUMNS,
|
columns: COMPANY_COLUMNS,
|
||||||
@ -65,23 +61,18 @@ export function CompaniesPage({
|
|||||||
const companies = table.getRowModel().rows.map((row) => row.original);
|
const companies = table.getRowModel().rows.map((row) => row.original);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<EntityListHeader
|
||||||
<div>
|
actions={
|
||||||
<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>{" "}
|
||||||
<Button onClick={() => setCreateOpen(true)}>{t("newCompany")}</Button>
|
</>
|
||||||
</div>
|
}
|
||||||
<div className="space-y-2">
|
description={t("help")}
|
||||||
<Label htmlFor={id}>{t("search")}</Label>
|
locale={i18n.language}
|
||||||
<Input
|
title={t("title")}
|
||||||
className="max-w-md"
|
|
||||||
id={id}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
placeholder={t("searchPlaceholder")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||||
<CompanyFeedback error={query.error} />
|
<CompanyFeedback error={query.error} />
|
||||||
{query.isError && (
|
{query.isError && (
|
||||||
@ -95,40 +86,52 @@ export function CompaniesPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{query.data && (
|
{query.data && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="pt-6">
|
columns={[
|
||||||
{companies.length === 0 ? (
|
{
|
||||||
<p className="text-muted-foreground">{t(search ? "noResults" : "empty")}</p>
|
id: "name",
|
||||||
) : (
|
label: t("name"),
|
||||||
<Table>
|
value: (company) => company.name,
|
||||||
<TableHeader>
|
hideable: false,
|
||||||
<TableRow>
|
render: (company) => (
|
||||||
<TableHead>{t("name")}</TableHead>
|
<button
|
||||||
<TableHead>{t("status")}</TableHead>
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
<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)}
|
onClick={() => selectCompany(company.id)}
|
||||||
size="sm"
|
type="button"
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
{t("open")}
|
{company.name}
|
||||||
</Button>
|
</button>
|
||||||
</TableCell>
|
),
|
||||||
</TableRow>
|
},
|
||||||
))}
|
{
|
||||||
</TableBody>
|
id: "state",
|
||||||
</Table>
|
label: t("status"),
|
||||||
)}
|
value: (company) => t(company.active ? "active" : "inactive"),
|
||||||
</CardContent>
|
render: (company) => (
|
||||||
</Card>
|
<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
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { AssetCatalogPage } from "@metis/assets/client";
|
import { AssetCatalogPage } from "@metis/assets/client";
|
||||||
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
|
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import {
|
import {
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
@ -43,39 +48,50 @@ import { PeopleSection } from "./people-section";
|
|||||||
import { SiteForm } from "./site-form";
|
import { SiteForm } from "./site-form";
|
||||||
|
|
||||||
function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||||
const { t } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||||
const selected = company.sites.find((site) => site.id === selectedId);
|
const selected = company.sites.find((site) => site.id === selectedId);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<EntityListHeader
|
||||||
<CardContent className="pt-6">
|
actions={<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>}
|
||||||
<div className="mb-4 flex items-center justify-between gap-4">
|
locale={i18n.language}
|
||||||
<h2 className="text-lg font-semibold">{t("sites")}</h2>
|
title={t("sites")}
|
||||||
<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>
|
/>
|
||||||
</div>
|
<EntityTable
|
||||||
{company.sites.length === 0 ? (
|
columns={[
|
||||||
<p className="text-muted-foreground">{t("noSites")}</p>
|
{
|
||||||
) : (
|
id: "name",
|
||||||
<div className="divide-y">
|
label: t("name"),
|
||||||
{company.sites.map((site) => (
|
value: (site) => site.name,
|
||||||
<div className="flex items-start justify-between gap-4 py-4" key={site.id}>
|
hideable: false,
|
||||||
<div className="min-w-0">
|
render: (site) => <span className="font-medium">{site.name}</span>,
|
||||||
<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")}
|
id: "address",
|
||||||
</p>
|
label: t("addressOptional"),
|
||||||
</div>
|
value: (site) => site.address,
|
||||||
<Button onClick={() => setSelectedId(site.id)} size="sm" variant="outline">
|
render: (site) => (
|
||||||
{t("edit")}
|
<span className="whitespace-pre-wrap">{site.address ?? t("noAddress")}</span>
|
||||||
</Button>
|
),
|
||||||
</div>
|
},
|
||||||
))}
|
{
|
||||||
</div>
|
id: "actions",
|
||||||
)}
|
label: t("actions"),
|
||||||
</CardContent>
|
hideable: false,
|
||||||
</Card>
|
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
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
formActions
|
formActions
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { 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 { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@ -106,50 +104,69 @@ export function DepartmentSection({
|
|||||||
company: CompanyDTO;
|
company: CompanyDTO;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const [editor, setEditor] = useState<string | undefined>();
|
const [editor, setEditor] = useState<string | undefined>();
|
||||||
const selected = company.departments.find((item) => item.id === editor);
|
const selected = company.departments.find((item) => item.id === editor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-5">
|
||||||
<CardContent className="space-y-4 pt-6">
|
<EntityListHeader
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
actions={<Button onClick={() => setEditor("new")}>{t("newDepartment")}</Button>}
|
||||||
<h2 className="text-lg font-semibold">{t("departments")}</h2>
|
locale={i18n.language}
|
||||||
<Button onClick={() => setEditor("new")}>{t("newDepartment")}</Button>
|
title={t("departments")}
|
||||||
</div>
|
/>
|
||||||
{company.departments.length ? (
|
<EntityTable
|
||||||
<Table>
|
columns={[
|
||||||
<TableHeader>
|
{
|
||||||
<TableRow>
|
id: "name",
|
||||||
<TableHead>{t("name")}</TableHead>
|
label: t("name"),
|
||||||
<TableHead>{t("status")}</TableHead>
|
value: (item) => item.name,
|
||||||
<TableHead>{t("people")}</TableHead>
|
hideable: false,
|
||||||
<TableHead>{t("actions")}</TableHead>
|
render: (item) => <>{item.name}</>,
|
||||||
</TableRow>
|
},
|
||||||
</TableHeader>
|
{
|
||||||
<TableBody>
|
id: "state",
|
||||||
{company.departments.map((department) => (
|
label: t("status"),
|
||||||
<TableRow key={department.id}>
|
value: (item) => t(item.active ? "active" : "inactive"),
|
||||||
<TableCell>{department.name}</TableCell>
|
render: (item) => (
|
||||||
<TableCell>{t(department.active ? "active" : "inactive")}</TableCell>
|
<EntityStatus active={item.active}>
|
||||||
<TableCell>
|
{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(
|
company.assignments.filter(
|
||||||
(item) => item.departmentId === department.id && item.endDate === null
|
(entry) => entry.departmentId === item.id && entry.endDate === null
|
||||||
).length
|
).length
|
||||||
}
|
}
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>
|
),
|
||||||
<Button onClick={() => setEditor(department.id)} size="sm" variant="outline">
|
},
|
||||||
{t("edit")}
|
{
|
||||||
</Button>
|
id: "actions",
|
||||||
</TableCell>
|
label: t("actions"),
|
||||||
</TableRow>
|
hideable: false,
|
||||||
))}
|
render: (item) => (
|
||||||
</TableBody>
|
<EntityRowActions label={t("actions")}>
|
||||||
</Table>
|
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||||
) : (
|
</EntityRowActions>
|
||||||
<p className="text-muted-foreground">{t("noDepartments")}</p>
|
),
|
||||||
)}
|
},
|
||||||
|
]}
|
||||||
|
emptyMessage={t("noDepartments")}
|
||||||
|
items={company.departments}
|
||||||
|
locale={i18n.language}
|
||||||
|
title={t("departments")}
|
||||||
|
/>
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
formActions
|
formActions
|
||||||
@ -169,7 +186,6 @@ export function DepartmentSection({
|
|||||||
onSaved={() => setEditor(undefined)}
|
onSaved={() => setEditor(undefined)}
|
||||||
/>
|
/>
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
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 {
|
import {
|
||||||
Table,
|
EntityAction,
|
||||||
TableBody,
|
EntityListHeader,
|
||||||
TableCell,
|
EntityRowActions,
|
||||||
TableHead,
|
EntityStatus,
|
||||||
TableHeader,
|
EntityTable,
|
||||||
TableRow,
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
} from "@repo/shadcn-ui/components/table";
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -82,57 +80,73 @@ function PersonDetails({
|
|||||||
)}
|
)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="space-y-4 pt-6">
|
<CardContent className="space-y-4 pt-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h3 className="text-lg font-semibold">{t("departmentAssignments")}</h3>
|
actions={
|
||||||
<Button disabled={!person.active} onClick={() => setEditor("new")}>
|
<Button disabled={!person.active} onClick={() => setEditor("new")}>
|
||||||
{t("addAssignment")}
|
{t("addAssignment")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
{assignments.length ? (
|
locale={i18n.language}
|
||||||
<Table>
|
title={t("departmentAssignments")}
|
||||||
<TableHeader>
|
/>
|
||||||
<TableRow>
|
<EntityTable
|
||||||
<TableHead>{t("department")}</TableHead>
|
columns={[
|
||||||
<TableHead>{t("position")}</TableHead>
|
{
|
||||||
<TableHead>{t("startDate")}</TableHead>
|
id: "department",
|
||||||
<TableHead>{t("endDate")}</TableHead>
|
label: t("department"),
|
||||||
<TableHead>{t("primary")}</TableHead>
|
value: (item) => departmentName(item.departmentId),
|
||||||
<TableHead>{t("actions")}</TableHead>
|
hideable: false,
|
||||||
</TableRow>
|
render: (item) => (
|
||||||
</TableHeader>
|
<>
|
||||||
<TableBody>
|
|
||||||
{assignments.map((item) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell>
|
|
||||||
{departmentName(item.departmentId)}
|
{departmentName(item.departmentId)}
|
||||||
{company.departments.find((department) => department.id === item.departmentId)
|
{company.departments.find((entry) => entry.id === item.departmentId)?.active ===
|
||||||
?.active === false && (
|
false && <p className="text-xs">{t("inactiveDepartment")}</p>}
|
||||||
<span className="block text-xs text-muted-foreground">
|
</>
|
||||||
{t("inactiveDepartment")}
|
),
|
||||||
</span>
|
},
|
||||||
)}
|
{
|
||||||
</TableCell>
|
id: "position",
|
||||||
<TableCell>{item.position ?? "—"}</TableCell>
|
label: t("position"),
|
||||||
<TableCell>{item.startDate}</TableCell>
|
value: (item) => item.position,
|
||||||
<TableCell>{item.endDate ?? t("currentAssignment")}</TableCell>
|
render: (item) => <>{item.position ?? "—"}</>,
|
||||||
<TableCell>{item.isPrimary ? t("yes") : "—"}</TableCell>
|
},
|
||||||
<TableCell>
|
{
|
||||||
<div className="flex gap-2">
|
id: "start",
|
||||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
label: t("startDate"),
|
||||||
{t("edit")}
|
value: (item) => item.startDate,
|
||||||
</Button>
|
render: (item) => <>{item.startDate}</>,
|
||||||
<Button onClick={() => setHistoryId(item.id)} size="sm" variant="ghost">
|
},
|
||||||
|
{
|
||||||
|
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")}
|
{t("history")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
</div>
|
</EntityRowActions>
|
||||||
</TableCell>
|
),
|
||||||
</TableRow>
|
},
|
||||||
))}
|
]}
|
||||||
</TableBody>
|
emptyMessage={t("noAssignments")}
|
||||||
</Table>
|
items={assignments}
|
||||||
) : (
|
locale={i18n.language}
|
||||||
<p className="text-muted-foreground">{t("noAssignments")}</p>
|
title={t("departmentAssignments")}
|
||||||
)}
|
/>
|
||||||
{!company.departments.some((item) => item.active) && (
|
{!company.departments.some((item) => item.active) && (
|
||||||
<p className="text-sm text-muted-foreground">{t("createDepartmentFirst")}</p>
|
<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 }) {
|
export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||||
const { t } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const filterId = useId();
|
const filterId = useId();
|
||||||
const [selection, setSelection] = useState<
|
const [selection, setSelection] = useState<
|
||||||
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
||||||
@ -247,15 +261,87 @@ export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csr
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{personEditor}
|
{personEditor}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-lg font-semibold">{t("people")}</h2>
|
actions={
|
||||||
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>
|
<>
|
||||||
</div>
|
{" "}
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>{" "}
|
||||||
<div className="space-y-2">
|
</>
|
||||||
<Label htmlFor={filterId}>{t("searchPerson")}</Label>
|
}
|
||||||
<Input id={filterId} onChange={(event) => setSearch(event.target.value)} value={search} />
|
locale={i18n.language}
|
||||||
</div>
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={contactsOnly}
|
checked={contactsOnly}
|
||||||
@ -264,64 +350,16 @@ export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csr
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</Label>
|
<Label htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<Card>
|
filtersApplied={contactsOnly}
|
||||||
<CardContent className="pt-6">
|
items={people}
|
||||||
{people.length ? (
|
locale={i18n.language}
|
||||||
<Table>
|
onClearFilters={() => setContactsOnly(false)}
|
||||||
<TableHeader>
|
onSearchChange={setSearch}
|
||||||
<TableRow>
|
search={search}
|
||||||
<TableHead>{t("name")}</TableHead>
|
searchPlaceholder={t("searchPerson")}
|
||||||
<TableHead>{t("position")}</TableHead>
|
title={t("people")}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,76 +1,263 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import {Input} from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {CompanyDTO,AccessItemDTO} from "../../common";
|
|
||||||
|
import type { AccessItemDTO, CompanyDTO } from "../../common";
|
||||||
import { accessApi, accessKeys } from "../access/access-api";
|
import { accessApi, accessKeys } from "../access/access-api";
|
||||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||||
|
|
||||||
import { folderApi, folderKeys } from "./folder-api";
|
import { folderApi, folderKeys } from "./folder-api";
|
||||||
import { FolderFeedback } from "./folder-feedback";
|
import { FolderFeedback } from "./folder-feedback";
|
||||||
import { FolderMappingForm } from "./folder-mapping-form";
|
import { FolderMappingForm } from "./folder-mapping-form";
|
||||||
|
|
||||||
export function FolderMappingsPage({company,csrfToken,initialProfileId}:{
|
export function FolderMappingsPage({
|
||||||
company:CompanyDTO;csrfToken:string;initialProfileId?:string;
|
company,
|
||||||
|
csrfToken,
|
||||||
|
initialProfileId,
|
||||||
|
}: {
|
||||||
|
company: CompanyDTO;
|
||||||
|
csrfToken: string;
|
||||||
|
initialProfileId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const [editor,setEditor]=useState<string>(),[view,setView]=useState<string>(),[search,setSearch]=useState("");
|
const [editor, setEditor] = useState<string>();
|
||||||
const query=useQuery({queryKey:folderKeys.company(company.id),queryFn:()=>folderApi.get(company.id)});
|
const [view, setView] = useState<string>();
|
||||||
const access=useQuery({queryKey:accessKeys.scope(company.id),queryFn:()=>accessApi.request(company.id)});
|
const [search, setSearch] = useState("");
|
||||||
const profiles=useQuery({queryKey:profileKeys.company(company.id),queryFn:()=>profileApi.get(company.id)});
|
const query = useQuery({
|
||||||
const items=access.data?.items ?? [],mappings=query.data?.mappings ?? [];
|
queryKey: folderKeys.company(company.id),
|
||||||
const profile=(id:string)=>profiles.data?.profiles.find(item=>item.id===id)?.values.name ?? 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 folder = (id: string) => folderDetails(items, id);
|
||||||
const filtered=mappings.filter(item=>(!initialProfileId || item.values.profileId===initialProfileId) &&
|
const filtered = mappings.filter(
|
||||||
[item.values.path,profile(item.values.profileId),folder(item.values.needId).name].some(text=>text.toLowerCase().includes(search.trim().toLowerCase())));
|
(item) =>
|
||||||
const edited=mappings.find(item=>item.id===editor),viewed=mappings.find(item=>item.id===view);
|
(!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);
|
const ready = [query.data, access.data, profiles.data].every(Boolean);
|
||||||
return <div className="space-y-5">
|
return (
|
||||||
<header className="flex flex-wrap justify-between items-center gap-4"><h2 className="text-xl font-semibold">{t("folders.title")}</h2>
|
<div className="space-y-5">
|
||||||
<Button disabled={!ready} onClick={()=>setEditor("new")}>{t("folders.new")}</Button></header>
|
<EntityListHeader
|
||||||
<Input aria-label={t("folders.search")} placeholder={t("folders.search")} value={search} onChange={event=>setSearch(event.target.value)} />
|
actions={
|
||||||
{(query.isPending || access.isPending || profiles.isPending) && <p role="status">{t("loading")}</p>}
|
<>
|
||||||
|
{" "}
|
||||||
|
<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} />
|
<FolderFeedback error={query.error ?? access.error ?? profiles.error} />
|
||||||
{(query.isError || access.isError || profiles.isError) && <Button variant="outline" onClick={()=>{
|
{(query.isError || access.isError || profiles.isError) && (
|
||||||
Promise.all([query.refetch(),access.refetch(),profiles.refetch()]).catch(()=>undefined);
|
<Button
|
||||||
}}>{t("retry")}</Button>}
|
onClick={() => {
|
||||||
{ready && <Card><CardContent className="divide-y pt-3">
|
Promise.all([query.refetch(), access.refetch(), profiles.refetch()]).catch(
|
||||||
{!filtered.length && <p className="py-4 text-muted-foreground">{t(mappings.length ? "noResults":"folders.empty")}</p>}
|
() => undefined
|
||||||
{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>
|
variant="outline"
|
||||||
<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>
|
{t("retry")}
|
||||||
<p className="text-sm mt-1">{t("mail."+item.values.state)}</p>
|
</Button>
|
||||||
{!["removed","never_configured"].includes(item.values.state) && <p className="text-sm mt-2">
|
)}
|
||||||
{t(item.permissionsCovered ? "folders.covered":"folders.uncovered")}</p>}
|
{ready && (
|
||||||
{item.needsReview && <p className="text-sm mt-1 text-amber-700">{t("folders.review")}</p>}
|
<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>
|
</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>;
|
|
||||||
}
|
}
|
||||||
export function folderDetails(items: readonly AccessItemDTO[], id: string) {
|
export function folderDetails(items: readonly AccessItemDTO[], id: string) {
|
||||||
const need=items.find(item=>item.id===id)?.values;
|
const need = items.find((item) => item.id === id)?.values;
|
||||||
if(need?.kind!=="need"){return {name:id,permissions:""};}
|
if (need?.kind !== "need") {
|
||||||
const resource=items.find(item=>item.id===need.resourceId)?.values;
|
return { name: id, permissions: "" };
|
||||||
return {name:resource?.kind==="resource" ? resource.name:need.resourceId,
|
}
|
||||||
permissions:need.permissionIds.map(permission=>{const item=items.find(item=>item.id===permission)?.values;
|
const resource = items.find((item) => item.id === need.resourceId)?.values;
|
||||||
return item?.kind==="permission" ? item.name:permission;}).join(" · ")};
|
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(" · "),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
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 { 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 { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
@ -18,7 +24,7 @@ export function InterventionTemplatesPage({
|
|||||||
scope?: string;
|
scope?: string;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const prefix = useId();
|
const prefix = useId();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: interventionTemplateKeys.list(scope),
|
queryKey: interventionTemplateKeys.list(scope),
|
||||||
@ -37,40 +43,24 @@ export function InterventionTemplatesPage({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("interventionTemplates.title")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button disabled={!query.data || query.isError} onClick={() => setEditor({})}>
|
<Button disabled={!query.data || query.isError} onClick={() => setEditor({})}>
|
||||||
{t("interventionTemplates.new")}
|
{t("interventionTemplates.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<p className="text-sm text-muted-foreground">
|
}
|
||||||
{t(
|
description={t(
|
||||||
scope === "global"
|
scope === "global"
|
||||||
? "interventionTemplates.scopeGlobal"
|
? "interventionTemplates.scopeGlobal"
|
||||||
: "interventionTemplates.scopeCompany"
|
: "interventionTemplates.scopeCompany"
|
||||||
)}
|
)}
|
||||||
</p>
|
locale={i18n.language}
|
||||||
<div className="flex flex-wrap gap-4 items-center">
|
title={t("interventionTemplates.title")}
|
||||||
<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>
|
|
||||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||||
<InterventionTemplateFeedback error={query.error} />
|
<InterventionTemplateFeedback error={query.error} />
|
||||||
{query.isError && (
|
{query.isError && (
|
||||||
@ -84,46 +74,106 @@ export function InterventionTemplatesPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{query.data && !query.isError && (
|
{query.data && !query.isError && (
|
||||||
|
<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) => (
|
||||||
<>
|
<>
|
||||||
{!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(
|
{t(
|
||||||
template.companyId
|
template.companyId
|
||||||
? "interventionTemplates.company"
|
? "interventionTemplates.company"
|
||||||
: "interventionTemplates.global"
|
: "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>
|
|
||||||
)}
|
)}
|
||||||
<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
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
|||||||
@ -67,12 +67,14 @@ async function render(node: ReactNode, language = "es") {
|
|||||||
client.clear();
|
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" />);
|
const global = await render(<InterventionTemplatesPage csrfToken="test" />);
|
||||||
assert.ok(global.includes("Plantillas de intervención"));
|
assert.ok(global.includes("Plantillas de intervención"));
|
||||||
assert.equal(global.includes("<form"), false);
|
assert.equal(global.includes("<form"), false);
|
||||||
const scoped = await render(<InterventionTemplatesPage csrfToken="test" scope={companyId} />);
|
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");
|
const english = await render(<InterventionTemplatesPage csrfToken="test" />, "en");
|
||||||
assert.ok(english.includes("Intervention templates"));
|
assert.ok(english.includes("Intervention templates"));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { 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 { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@ -76,10 +82,10 @@ export function InterventionsPage({
|
|||||||
: "";
|
: "";
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex items-center justify-between gap-3">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">
|
actions={
|
||||||
{t(companyId ? "interventions.title" : "interventions.global")}
|
<>
|
||||||
</h2>
|
{" "}
|
||||||
<Button
|
<Button
|
||||||
disabled={!query.data || query.isError}
|
disabled={!query.data || query.isError}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -88,15 +94,161 @@ export function InterventionsPage({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("interventions.new")}
|
{t("interventions.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
}
|
||||||
<Input
|
locale={i18n.language}
|
||||||
aria-label={t("interventions.search")}
|
title={t(companyId ? "interventions.title" : "interventions.global")}
|
||||||
onChange={(event) => setFilters({ ...filters, search: event.target.value })}
|
|
||||||
placeholder={t("interventions.search")}
|
|
||||||
value={filters.search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
|
||||||
|
{query.isError && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InterventionFeedback error={query.error} />
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
query.refetch().catch(() => undefined);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 && (
|
{!companyId && (
|
||||||
<SelectWithAction
|
<SelectWithAction
|
||||||
id="interventions-company-filter"
|
id="interventions-company-filter"
|
||||||
@ -131,103 +283,22 @@ export function InterventionsPage({
|
|||||||
]}
|
]}
|
||||||
placeholder={t("interventions.allAssignees")}
|
placeholder={t("interventions.allAssignees")}
|
||||||
value={filters.assignee}
|
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"
|
filtersApplied={Object.values(filters).some(Boolean)}
|
||||||
>
|
items={items}
|
||||||
{t("interventions.resetFilters")}
|
loading={query.isPending}
|
||||||
</Button>
|
locale={i18n.language}
|
||||||
</div>
|
onClearFilters={() =>
|
||||||
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
|
setFilters({ search: "", company: "", state: "", assignee: "", dateFrom: "", dateTo: "" })
|
||||||
{query.isError && (
|
}
|
||||||
<div className="space-y-3">
|
onSearchChange={(search) => setFilters({ ...filters, search })}
|
||||||
<InterventionFeedback error={query.error} />
|
search={filters.search}
|
||||||
<Button
|
searchPlaceholder={t("interventions.search")}
|
||||||
onClick={() => {
|
title={t(companyId ? "interventions.title" : "interventions.global")}
|
||||||
query.refetch().catch(() => undefined);
|
unavailable={query.isError}
|
||||||
}}
|
/>
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{t("retry")}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("tickets.updated")}: {date(intervention.history.at(-1)?.recordedAt)}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
formActions
|
formActions
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -77,21 +82,22 @@ export function MailConfigurationsPage({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("mail.title")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button
|
<Button
|
||||||
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
|
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
|
||||||
onClick={() => setEditor("new")}
|
onClick={() => setEditor("new")}
|
||||||
>
|
>
|
||||||
{t("mail.new")}
|
{t("mail.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("mail.search")}
|
locale={i18n.language}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
title={t("mail.title")}
|
||||||
placeholder={t("mail.search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(query.isPending || inventory.isPending || profiles.isPending || mailboxes.isPending) && (
|
{(query.isPending || inventory.isPending || profiles.isPending || mailboxes.isPending) && (
|
||||||
<p role="status">{t("loading")}</p>
|
<p role="status">{t("loading")}</p>
|
||||||
)}
|
)}
|
||||||
@ -112,40 +118,84 @@ export function MailConfigurationsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{query.data && (
|
{query.data && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{!filtered.length && (
|
{
|
||||||
<p className="py-4 text-muted-foreground">
|
id: "name",
|
||||||
{t(configurations.length ? "noResults" : "mail.empty")}
|
label: t("mail.address"),
|
||||||
</p>
|
value: (item) => address(item),
|
||||||
)}
|
hideable: false,
|
||||||
{filtered.map((item) => (
|
render: (item) => (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
|
<button
|
||||||
<div className="min-w-0 break-words">
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
<h3 className="font-medium">
|
onClick={() => setView(item.id)}
|
||||||
{address(item)} · {t(`mail.${item.values.method}`)}
|
type="button"
|
||||||
</h3>
|
>
|
||||||
<p className="text-sm text-muted-foreground">{target(item.values)}</p>
|
{address(item)}
|
||||||
<p className="mt-1 text-sm">{t(`mail.${item.values.state}`)}</p>
|
</button>
|
||||||
{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>
|
{
|
||||||
)}
|
id: "profile",
|
||||||
</div>
|
label: t("profiles.title"),
|
||||||
<div className="flex gap-2">
|
value: (item) => target(item.values),
|
||||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
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")}
|
{t("mail.history")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||||
{t("edit")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>{" "}
|
||||||
</div>
|
</EntityRowActions>
|
||||||
))}
|
),
|
||||||
</CardContent>
|
},
|
||||||
</Card>
|
]}
|
||||||
|
emptyMessage={t("mail.empty")}
|
||||||
|
items={filtered}
|
||||||
|
locale={i18n.language}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
search={search}
|
||||||
|
searchPlaceholder={t("mail.search")}
|
||||||
|
title={t("mail.title")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{editor && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -22,7 +27,7 @@ export function PrinterConfigurationsPage({
|
|||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
initialProfileId?: string;
|
initialProfileId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const [editor, setEditor] = useState<string>();
|
const [editor, setEditor] = useState<string>();
|
||||||
const [view, setView] = useState<string>();
|
const [view, setView] = useState<string>();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@ -61,18 +66,19 @@ export function PrinterConfigurationsPage({
|
|||||||
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
|
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("printers.title")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||||
{t("printers.new")}
|
{t("printers.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("printers.search")}
|
locale={i18n.language}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
title={t("printers.title")}
|
||||||
placeholder={t("printers.search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(query.isPending || profiles.isPending || inventory.isPending) && (
|
{(query.isPending || profiles.isPending || inventory.isPending) && (
|
||||||
<p role="status">{t("loading")}</p>
|
<p role="status">{t("loading")}</p>
|
||||||
)}
|
)}
|
||||||
@ -90,40 +96,84 @@ export function PrinterConfigurationsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{ready && (
|
{ready && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{!filtered.length && (
|
{
|
||||||
<p className="py-4 text-muted-foreground">{t("printers.empty")}</p>
|
id: "name",
|
||||||
)}
|
label: t("printers.title"),
|
||||||
{filtered.map((item) => (
|
value: (item) => asset(item.values.printerId),
|
||||||
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
|
hideable: false,
|
||||||
<div>
|
render: (item) => (
|
||||||
<h3 className="font-medium">{asset(item.values.printerId)}</h3>
|
<button
|
||||||
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
<p className="text-sm">
|
onClick={() => setView(item.id)}
|
||||||
{t(`printers.${item.values.connection}`)} · {t(`mail.${item.values.state}`)}
|
type="button"
|
||||||
</p>
|
>
|
||||||
{item.values.isDefault && (
|
{asset(item.values.printerId)}
|
||||||
<p className="text-sm font-medium">{t("printers.default")}</p>
|
</button>
|
||||||
)}
|
),
|
||||||
{item.needsReview && (
|
},
|
||||||
<p className="text-sm text-amber-700">{t("printers.review")}</p>
|
{
|
||||||
)}
|
id: "profile",
|
||||||
</div>
|
label: t("profiles.title"),
|
||||||
<div className="flex items-start gap-2">
|
value: (item) => label(item.values.profileId),
|
||||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
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")}
|
{t("mail.history")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||||
{t("edit")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>{" "}
|
||||||
</div>
|
</EntityRowActions>
|
||||||
))}
|
),
|
||||||
</CardContent>
|
},
|
||||||
</Card>
|
]}
|
||||||
|
emptyMessage={t("printers.empty")}
|
||||||
|
items={filtered}
|
||||||
|
locale={i18n.language}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
search={search}
|
||||||
|
searchPlaceholder={t("printers.search")}
|
||||||
|
title={t("printers.title")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{editor && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
|
|||||||
@ -63,7 +63,7 @@ test("impresoras: listado sin formularios, alta en cabecera y traducciones", asy
|
|||||||
assert.ok(html.includes(title ?? ""));
|
assert.ok(html.includes(title ?? ""));
|
||||||
assert.ok(html.includes(action ?? ""));
|
assert.ok(html.includes(action ?? ""));
|
||||||
assert.ok(!html.includes("<form"));
|
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 () => {
|
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.includes(text.printers.newAsset));
|
||||||
assert.ok(html.indexOf(text.printers.help) > html.indexOf(text.printers.connection));
|
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.indexOf(text.save) > html.indexOf(text.printers.help));
|
||||||
assert.ok(!html.includes("printers."));
|
assert.equal(/(?:>|")printers\./.test(html), false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@ -9,7 +15,7 @@ import { processApi, processKeys } from "./process-api";
|
|||||||
import { ProcessFeedback } from "./process-feedback";
|
import { ProcessFeedback } from "./process-feedback";
|
||||||
import { ProcessTemplateForm } from "./process-template-form";
|
import { ProcessTemplateForm } from "./process-template-form";
|
||||||
export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
|
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 query = useQuery({ queryKey: processKeys.catalog(), queryFn: processApi.catalog });
|
||||||
const [editor, setEditor] = useState<string>();
|
const [editor, setEditor] = useState<string>();
|
||||||
const [copy, setCopy] = useState<string>();
|
const [copy, setCopy] = useState<string>();
|
||||||
@ -22,13 +28,20 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("processes.templates")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
|
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
|
||||||
{t("processes.newTemplate")}
|
{t("processes.newTemplate")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<p className="text-sm text-muted-foreground">{t("processes.globalTemplates")}</p>
|
}
|
||||||
|
description={t("processes.globalTemplates")}
|
||||||
|
locale={i18n.language}
|
||||||
|
title={t("processes.templates")}
|
||||||
|
/>
|
||||||
|
|
||||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||||
<ProcessFeedback error={query.error} />
|
<ProcessFeedback error={query.error} />
|
||||||
{query.isError && (
|
{query.isError && (
|
||||||
@ -42,44 +55,56 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{query.data && !query.isError && (
|
{query.data && !query.isError && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{templates.map((template) => (
|
{
|
||||||
<section className="space-y-3 py-4" key={template.id}>
|
id: "name",
|
||||||
<header className="flex flex-wrap justify-between gap-3">
|
label: t("name"),
|
||||||
<div>
|
value: (template) => template.values.name,
|
||||||
<h3 className="font-medium">{template.values.name}</h3>
|
hideable: false,
|
||||||
<p className="text-sm text-muted-foreground">
|
render: (template) => <>{template.values.name}</>,
|
||||||
{t(`processes.${template.values.kind}`)} ·{" "}
|
},
|
||||||
|
{
|
||||||
|
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")}
|
{t(template.values.active ? "active" : "inactive")}
|
||||||
</p>
|
</EntityStatus>
|
||||||
</div>
|
),
|
||||||
<div className="flex gap-2">
|
},
|
||||||
<Button onClick={() => setEditor(template.id)} size="sm" variant="outline">
|
{
|
||||||
{t("edit")}
|
id: "steps",
|
||||||
</Button>
|
label: t("processes.instructions"),
|
||||||
<Button
|
render: (template) => (
|
||||||
onClick={() => {
|
<details>
|
||||||
setCopy(template.id);
|
<summary className="cursor-pointer">
|
||||||
setEditor("new");
|
{t("processes.instructions")} ({template.values.instructions.length})
|
||||||
}}
|
</summary>
|
||||||
size="sm"
|
<ol className="list-decimal pl-5">
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{t("processes.copy")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<ol className="list-decimal pl-5 text-sm">
|
|
||||||
{template.values.instructions.map((instruction) => (
|
{template.values.instructions.map((instruction) => (
|
||||||
<li key={instruction}>{instruction}</li>
|
<li key={instruction}>{instruction}</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
</details>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "history",
|
||||||
|
label: t("processes.history"),
|
||||||
|
render: (template) => (
|
||||||
<details>
|
<details>
|
||||||
<summary className="cursor-pointer text-sm">{t("processes.history")}</summary>
|
<summary className="cursor-pointer">{t("processes.history")}</summary>
|
||||||
<ol className="space-y-2 mt-2">
|
<ol className="space-y-2">
|
||||||
{[...template.history].reverse().map((r) => (
|
{[...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}
|
{r.recordedAt} · {r.actorName}
|
||||||
<p>
|
<p>
|
||||||
{r.values.name} · {t(r.values.active ? "active" : "inactive")}
|
{r.values.name} · {t(r.values.active ? "active" : "inactive")}
|
||||||
@ -94,10 +119,34 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</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
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -14,7 +19,7 @@ import { ProcessFeedback } from "./process-feedback";
|
|||||||
import { ProcessStartForm } from "./process-start-form";
|
import { ProcessStartForm } from "./process-start-form";
|
||||||
import { ProcessTemplatesPage } from "./process-templates-page";
|
import { ProcessTemplatesPage } from "./process-templates-page";
|
||||||
export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||||
const { t } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
const [templates, setTemplates] = useState(false);
|
const [templates, setTemplates] = useState(false);
|
||||||
const [create, setCreate] = useState(false);
|
const [create, setCreate] = useState(false);
|
||||||
const [selected, setSelected] = useState<string>();
|
const [selected, setSelected] = useState<string>();
|
||||||
@ -72,8 +77,10 @@ export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csr
|
|||||||
) : (
|
) : (
|
||||||
!selected && (
|
!selected && (
|
||||||
<>
|
<>
|
||||||
<header className="flex flex-wrap justify-between gap-3">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("processes.title")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => setTemplates(true)} variant="outline">
|
<Button onClick={() => setTemplates(true)} variant="outline">
|
||||||
{t("processes.templates")}
|
{t("processes.templates")}
|
||||||
@ -81,14 +88,83 @@ export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csr
|
|||||||
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
|
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
|
||||||
{t("processes.new")}
|
{t("processes.new")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("processes.search")}
|
locale={i18n.language}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
title={t("processes.title")}
|
||||||
placeholder={t("processes.search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{query.data && !query.isError && (
|
||||||
|
<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">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
checked={closed}
|
checked={closed}
|
||||||
@ -97,37 +173,16 @@ export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csr
|
|||||||
/>
|
/>
|
||||||
{t("processes.showClosed")}
|
{t("processes.showClosed")}
|
||||||
</label>
|
</label>
|
||||||
{query.data && !query.isError && (
|
}
|
||||||
<Card>
|
filtersApplied={closed}
|
||||||
<CardContent className="divide-y pt-3">
|
items={filtered}
|
||||||
{!filtered.length && (
|
locale={i18n.language}
|
||||||
<p className="py-4 text-muted-foreground">
|
onClearFilters={() => setClosed(false)}
|
||||||
{t(processes.length ? "noResults" : "processes.empty")}
|
onSearchChange={setSearch}
|
||||||
</p>
|
search={search}
|
||||||
)}
|
searchPlaceholder={t("processes.search")}
|
||||||
{filtered.map((p) => (
|
title={t("processes.title")}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -95,26 +100,19 @@ export function WorkProfilesPage({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("profiles.title")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||||
{t("profiles.new")}
|
{t("profiles.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("profiles.search")}
|
locale={i18n.language}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
title={t("profiles.title")}
|
||||||
placeholder={t("profiles.search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
<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) && (
|
{(query.isPending || inventory.isPending || access.isPending) && (
|
||||||
<p role="status">{t("loading")}</p>
|
<p role="status">{t("loading")}</p>
|
||||||
)}
|
)}
|
||||||
@ -132,81 +130,125 @@ export function WorkProfilesPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{ready && (
|
{ready && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{!filtered.length && (
|
{
|
||||||
<p className="py-4 text-muted-foreground">
|
id: "name",
|
||||||
{t(profiles.length ? "noResults" : "profiles.empty")}
|
label: t("name"),
|
||||||
</p>
|
value: (item) => item.values.name,
|
||||||
)}
|
hideable: false,
|
||||||
{filtered.map((item) => (
|
render: (item) => (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
|
<button
|
||||||
<div className="min-w-0 break-words">
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
<h3 className="font-medium">
|
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
|
||||||
{item.values.name} ·{" "}
|
type="button"
|
||||||
{t(item.values.active ? "profiles.active" : "profiles.inactive")}
|
>
|
||||||
</h3>
|
{item.values.name}
|
||||||
<p className="text-sm text-muted-foreground">
|
</button>
|
||||||
{person(item.values.personId)} · {asset(item.values.assetId)}
|
),
|
||||||
</p>
|
},
|
||||||
<p className="mt-1 text-sm">{account(item.values.accountId)}</p>
|
{
|
||||||
{item.needsReview && (
|
id: "person",
|
||||||
<p className="mt-2 text-sm text-amber-700">{t("profiles.review")}</p>
|
label: t("people"),
|
||||||
)}
|
value: (item) => person(item.values.personId),
|
||||||
</div>
|
render: (item) => <>{person(item.values.personId)}</>,
|
||||||
<div className="flex flex-wrap gap-2">
|
},
|
||||||
<Button
|
{
|
||||||
|
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 })}
|
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
|
||||||
size="sm"
|
|
||||||
>
|
>
|
||||||
{t("environment.title")}
|
{t("environment.title")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button
|
<EntityAction
|
||||||
onClick={() => setConfiguration({ kind: "certificates", profileId: item.id })}
|
onClick={() => setConfiguration({ kind: "certificates", profileId: item.id })}
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
{t("certificates.title")}
|
{t("certificates.title")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button
|
<EntityAction
|
||||||
onClick={() => setConfiguration({ kind: "applications", profileId: item.id })}
|
onClick={() => setConfiguration({ kind: "applications", profileId: item.id })}
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
{t("applications.title")}
|
{t("applications.title")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button
|
<EntityAction
|
||||||
onClick={() => setConfiguration({ kind: "printers", profileId: item.id })}
|
onClick={() => setConfiguration({ kind: "printers", profileId: item.id })}
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
{t("printers.title")}
|
{t("printers.title")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button
|
<EntityAction
|
||||||
onClick={() => setConfiguration({ kind: "folders", profileId: item.id })}
|
onClick={() => setConfiguration({ kind: "folders", profileId: item.id })}
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
{t("folders.title")}
|
{t("folders.title")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button
|
<EntityAction
|
||||||
onClick={() => setConfiguration({ kind: "mail", profileId: item.id })}
|
onClick={() => setConfiguration({ kind: "mail", profileId: item.id })}
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
{t("mail.title")}
|
{t("mail.title")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
<EntityAction onClick={() => setView(item.id)}>
|
||||||
{t("profiles.history")}
|
{t("profiles.history")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||||
{t("edit")}
|
</div>{" "}
|
||||||
</Button>
|
</EntityRowActions>
|
||||||
</div>
|
),
|
||||||
</div>
|
},
|
||||||
))}
|
]}
|
||||||
</CardContent>
|
emptyMessage={t("profiles.empty")}
|
||||||
</Card>
|
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 && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
|
|||||||
@ -4,9 +4,14 @@ import {
|
|||||||
inventoryKeys as resourceKeys,
|
inventoryKeys as resourceKeys,
|
||||||
} from "@metis/resources/client";
|
} from "@metis/resources/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -65,26 +70,19 @@ export function RemoteConnectionsPage({
|
|||||||
const ready = Boolean(query.data && assets.data && resources.data && !error);
|
const ready = Boolean(query.data && assets.data && resources.data && !error);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("remote.title")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||||
{t("remote.new")}
|
{t("remote.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("remote.search")}
|
locale={i18n.language}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
title={t("remote.title")}
|
||||||
placeholder={t("remote.search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
<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>}
|
{[query, assets, resources].some((q) => q.isPending) && <p role="status">{t("loading")}</p>}
|
||||||
<RemoteFeedback error={error} />
|
<RemoteFeedback error={error} />
|
||||||
{error && (
|
{error && (
|
||||||
@ -100,41 +98,92 @@ export function RemoteConnectionsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{ready && (
|
{ready && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{!filtered.length && (
|
{
|
||||||
<p className="py-4 text-muted-foreground">
|
id: "person",
|
||||||
{t(connections.length ? "noResults" : "remote.empty")}
|
label: t("people"),
|
||||||
</p>
|
value: (c) => person(c.values.personId),
|
||||||
)}
|
hideable: false,
|
||||||
{filtered.map((c) => (
|
render: (c) => (
|
||||||
<div className="space-y-3 py-4" key={c.id}>
|
<button
|
||||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
<h3 className="font-medium">{person(c.values.personId)}</h3>
|
onClick={() => setView(c.id)}
|
||||||
<div className="flex gap-2">
|
type="button"
|
||||||
<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")}
|
{person(c.values.personId)}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
),
|
||||||
</header>
|
},
|
||||||
|
{
|
||||||
|
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
|
<RemoteSummary
|
||||||
assets={inventory}
|
assets={inventory}
|
||||||
company={company}
|
company={company}
|
||||||
connection={c}
|
connection={c}
|
||||||
resources={entries}
|
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 && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -41,8 +46,10 @@ export function TeleworkAuthorizationsPage({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">{t("telework.title")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
!query.data ||
|
!query.data ||
|
||||||
@ -55,14 +62,13 @@ export function TeleworkAuthorizationsPage({
|
|||||||
onClick={() => setEditor("new")}
|
onClick={() => setEditor("new")}
|
||||||
>
|
>
|
||||||
{t("telework.new")}
|
{t("telework.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<Input
|
}
|
||||||
aria-label={t("telework.search")}
|
locale={i18n.language}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
title={t("telework.title")}
|
||||||
placeholder={t("telework.search")}
|
|
||||||
value={search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||||
<TeleworkFeedback error={query.error} />
|
<TeleworkFeedback error={query.error} />
|
||||||
{query.isError && (
|
{query.isError && (
|
||||||
@ -76,31 +82,74 @@ export function TeleworkAuthorizationsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{query.data && !query.isError && (
|
{query.data && !query.isError && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="divide-y pt-3">
|
columns={[
|
||||||
{!filtered.length && (
|
{
|
||||||
<p className="py-4 text-muted-foreground">
|
id: "person",
|
||||||
{t(authorizations.length && !initialPersonId ? "noResults" : "telework.empty")}
|
label: t("people"),
|
||||||
</p>
|
value: (item) => person(item.values.personId),
|
||||||
)}
|
hideable: false,
|
||||||
{filtered.map((item) => (
|
render: (item) => (
|
||||||
<div className="space-y-3 py-4" key={item.id}>
|
<button
|
||||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
<h3 className="font-medium">{person(item.values.personId)}</h3>
|
onClick={() => setView(item.id)}
|
||||||
<div className="flex gap-2">
|
type="button"
|
||||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
>
|
||||||
|
{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")}
|
{t("telework.history")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||||
{t("edit")}
|
</div>{" "}
|
||||||
</Button>
|
</EntityRowActions>
|
||||||
</div>
|
),
|
||||||
</header>
|
},
|
||||||
<TeleworkAuthorizationSummary authorization={item} />
|
]}
|
||||||
</div>
|
emptyMessage={t("telework.empty")}
|
||||||
))}
|
entityLabel={t("telework.authorizations")}
|
||||||
</CardContent>
|
items={filtered}
|
||||||
</Card>
|
locale={i18n.language}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
search={search}
|
||||||
|
searchPlaceholder={t("telework.search")}
|
||||||
|
title={t("telework.title")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{editor && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export const teleworkSpanish = {
|
export const teleworkSpanish = {
|
||||||
title: "Teletrabajo",
|
title: "Teletrabajo",
|
||||||
|
authorizations: "Autorizaciones de teletrabajo",
|
||||||
new: "Nueva autorización",
|
new: "Nueva autorización",
|
||||||
edit: "Editar autorización de teletrabajo",
|
edit: "Editar autorización de teletrabajo",
|
||||||
person: "Persona",
|
person: "Persona",
|
||||||
@ -48,6 +49,7 @@ export const teleworkSpanish = {
|
|||||||
};
|
};
|
||||||
export const teleworkEnglish = {
|
export const teleworkEnglish = {
|
||||||
title: "Telework",
|
title: "Telework",
|
||||||
|
authorizations: "Remote work authorizations",
|
||||||
new: "New authorization",
|
new: "New authorization",
|
||||||
edit: "Edit telework authorization",
|
edit: "Edit telework authorization",
|
||||||
person: "Person",
|
person: "Person",
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { 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 { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@ -67,10 +73,10 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
|||||||
: "";
|
: "";
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<header className="flex items-center justify-between gap-3">
|
<EntityListHeader
|
||||||
<h2 className="text-xl font-semibold">
|
actions={
|
||||||
{t(companyId ? "tickets.title" : "tickets.global")}
|
<>
|
||||||
</h2>
|
{" "}
|
||||||
<Button
|
<Button
|
||||||
disabled={!query.data}
|
disabled={!query.data}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -79,15 +85,133 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("tickets.new")}
|
{t("tickets.new")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</header>
|
</>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
}
|
||||||
<Input
|
locale={i18n.language}
|
||||||
aria-label={t("tickets.search")}
|
title={t(companyId ? "tickets.title" : "tickets.global")}
|
||||||
onChange={(event) => setFilters({ ...filters, search: event.target.value })}
|
|
||||||
placeholder={t("tickets.search")}
|
|
||||||
value={filters.search}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{query.isPending && <p role="status">{t("tickets.loading")}</p>}
|
||||||
|
{query.isError && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<TicketFeedback error={query.error} />
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
query.refetch().catch(() => undefined);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 && (
|
{!companyId && (
|
||||||
<SelectWithAction
|
<SelectWithAction
|
||||||
id="tickets-company-filter"
|
id="tickets-company-filter"
|
||||||
@ -133,64 +257,22 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
|||||||
]}
|
]}
|
||||||
placeholder={t("tickets.allAssignees")}
|
placeholder={t("tickets.allAssignees")}
|
||||||
value={filters.assignee}
|
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}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{query.isPending && <p role="status">{t("tickets.loading")}</p>}
|
|
||||||
{query.isError && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<TicketFeedback error={query.error} />
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
query.refetch().catch(() => undefined);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{t("retry")}
|
|
||||||
</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>
|
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
formActions
|
formActions
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 {
|
import {
|
||||||
Table,
|
EntityAction,
|
||||||
TableBody,
|
EntityListHeader,
|
||||||
TableCell,
|
EntityRowActions,
|
||||||
TableHead,
|
EntityStatus,
|
||||||
TableHeader,
|
EntityTable,
|
||||||
TableRow,
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
} from "@repo/shadcn-ui/components/table";
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -21,7 +19,7 @@ import { PasswordForm } from "./password-form";
|
|||||||
import { UserForm } from "./user-form";
|
import { UserForm } from "./user-form";
|
||||||
|
|
||||||
export function UserManagement({ csrfToken }: { csrfToken: string }) {
|
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 users = useQuery({ queryKey: identityKeys.users, queryFn: identityApi.users });
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [selection, setSelection] = useState<
|
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);
|
const selected: UserDTO | undefined = users.data?.users.find((user) => user.id === selection?.id);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<EntityListHeader
|
||||||
<div>
|
actions={
|
||||||
<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>{" "}
|
||||||
<Button onClick={() => setCreateOpen(true)}>{t("createUser")}</Button>
|
</>
|
||||||
</div>
|
}
|
||||||
|
description={t("usersHelp")}
|
||||||
|
locale={i18n.language}
|
||||||
|
title={t("users")}
|
||||||
|
/>
|
||||||
{users.isPending && <p role="status">{t("loading")}</p>}
|
{users.isPending && <p role="status">{t("loading")}</p>}
|
||||||
<FormFeedback error={users.error} />
|
<FormFeedback error={users.error} />
|
||||||
{users.isError && (
|
{users.isError && (
|
||||||
@ -50,57 +52,65 @@ export function UserManagement({ csrfToken }: { csrfToken: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{users.data && (
|
{users.data && (
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="pt-6">
|
columns={[
|
||||||
<Table>
|
{
|
||||||
<TableHeader>
|
id: "name",
|
||||||
<TableRow>
|
label: t("name"),
|
||||||
<TableHead>{t("name")}</TableHead>
|
value: (user) => user.name,
|
||||||
<TableHead>{t("email")}</TableHead>
|
hideable: false,
|
||||||
<TableHead>{t("status")}</TableHead>
|
render: (user) => (
|
||||||
<TableHead>{t("actions")}</TableHead>
|
<>
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{users.data.users.map((user) => (
|
|
||||||
<TableRow key={user.id}>
|
|
||||||
<TableCell>
|
|
||||||
<span className="font-medium">{user.name}</span>
|
<span className="font-medium">{user.name}</span>
|
||||||
{user.isInitialAdmin && (
|
{user.isInitialAdmin && (
|
||||||
<span className="mt-1 block text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t("initialAdmin")}</p>
|
||||||
{t("initialAdmin")}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>{user.email}</TableCell>
|
),
|
||||||
<TableCell>{t(user.active ? "active" : "inactive")}</TableCell>
|
},
|
||||||
<TableCell>
|
{
|
||||||
<div className="flex flex-wrap gap-2">
|
id: "email",
|
||||||
<Button
|
label: t("email"),
|
||||||
onClick={() => setSelection({ id: user.id, action: "edit" })}
|
value: (user) => user.email,
|
||||||
size="sm"
|
render: (user) => <>{user.email}</>,
|
||||||
variant="outline"
|
},
|
||||||
>
|
{
|
||||||
|
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")}
|
{t("edit")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
{!user.isInitialAdmin && (
|
{!user.isInitialAdmin && (
|
||||||
<Button
|
<EntityAction
|
||||||
onClick={() => setSelection({ id: user.id, action: "password" })}
|
onClick={() => setSelection({ id: user.id, action: "password" })}
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
>
|
||||||
{t("resetPassword")}
|
{t("resetPassword")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</EntityRowActions>
|
||||||
</TableRow>
|
),
|
||||||
))}
|
},
|
||||||
</TableBody>
|
]}
|
||||||
</Table>
|
emptyMessage={t("empty")}
|
||||||
{users.data.users.length === 0 && <p>{t("empty")}</p>}
|
items={users.data.users}
|
||||||
</CardContent>
|
locale={i18n.language}
|
||||||
</Card>
|
title={t("users")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 {
|
import {
|
||||||
Table,
|
EntityAction,
|
||||||
TableBody,
|
EntityListHeader,
|
||||||
TableCell,
|
EntityRowActions,
|
||||||
TableHead,
|
EntityStatus,
|
||||||
TableHeader,
|
EntityTable,
|
||||||
TableRow,
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
} from "@repo/shadcn-ui/components/table";
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -28,8 +24,8 @@ type Editor = {
|
|||||||
copy?: CatalogItemDTO;
|
copy?: CatalogItemDTO;
|
||||||
};
|
};
|
||||||
export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
||||||
const { t } = useTranslation("resources");
|
const { t, i18n } = useTranslation("resources");
|
||||||
const searchId = useId();
|
const _searchId = useId();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [tab, setTab] = useState<"field" | "template">("template");
|
const [tab, setTab] = useState<"field" | "template">("template");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@ -55,19 +51,18 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<EntityListHeader
|
||||||
<div>
|
actions={
|
||||||
<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 })}>
|
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||||
{t({ field: "newField", template: "newTemplate" }[tab])}
|
{t({ field: "newField", template: "newTemplate" }[tab])}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||||
|
locale={i18n.language}
|
||||||
|
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||||
|
/>
|
||||||
<nav aria-label={t("title")} className="flex gap-2">
|
<nav aria-label={t("title")} className="flex gap-2">
|
||||||
{(["template", "field"] as const).map((kind) => (
|
{(["template", "field"] as const).map((kind) => (
|
||||||
<Button
|
<Button
|
||||||
@ -96,34 +91,18 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
|
|||||||
{t("retry")}
|
{t("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="space-y-4 pt-6">
|
columns={[
|
||||||
<div className="space-y-2">
|
{
|
||||||
<Label htmlFor={searchId}>{t("search")}</Label>
|
id: "name",
|
||||||
<Input
|
label: t("name"),
|
||||||
id={searchId}
|
value: (item) => item.values.name,
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
hideable: false,
|
||||||
value={search}
|
render: (item) => (
|
||||||
/>
|
<CatalogCell
|
||||||
</div>
|
column="name"
|
||||||
{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}
|
item={item}
|
||||||
items={items}
|
items={items}
|
||||||
key={item.id}
|
|
||||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||||
onEdit={() => setEditor({ kind: tab, item })}
|
onEdit={() => setEditor({ kind: tab, item })}
|
||||||
onStatus={(active) => mutation.mutate({ item, active })}
|
onStatus={(active) => mutation.mutate({ item, active })}
|
||||||
@ -131,14 +110,92 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
|
|||||||
pending={mutation.isPending}
|
pending={mutation.isPending}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
/>
|
/>
|
||||||
))}
|
),
|
||||||
</TableBody>
|
},
|
||||||
</Table>
|
{
|
||||||
) : (
|
id: "origin",
|
||||||
<p className="text-muted-foreground">{t("empty")}</p>
|
label: t("origin"),
|
||||||
))}
|
value: (item) => t(item.builtin ? "builtin" : "custom"),
|
||||||
</CardContent>
|
render: (item) => (
|
||||||
</Card>
|
<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)} />
|
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
|
||||||
{editor && (
|
{editor && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -175,7 +232,8 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function CatalogRow({
|
function CatalogCell({
|
||||||
|
column,
|
||||||
item,
|
item,
|
||||||
scope,
|
scope,
|
||||||
items,
|
items,
|
||||||
@ -185,6 +243,7 @@ function CatalogRow({
|
|||||||
onView,
|
onView,
|
||||||
onStatus,
|
onStatus,
|
||||||
}: {
|
}: {
|
||||||
|
column: "name" | "origin" | "details" | "state" | "actions";
|
||||||
item: CatalogItemDTO;
|
item: CatalogItemDTO;
|
||||||
scope: string;
|
scope: string;
|
||||||
items: readonly CatalogItemDTO[];
|
items: readonly CatalogItemDTO[];
|
||||||
@ -197,40 +256,41 @@ function CatalogRow({
|
|||||||
const { t } = useTranslation("resources");
|
const { t } = useTranslation("resources");
|
||||||
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
|
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
|
||||||
const values = item.values;
|
const values = item.values;
|
||||||
const inactiveFields =
|
switch (column) {
|
||||||
values.kind === "template" &&
|
case "name":
|
||||||
values.fields.some(
|
return <CatalogName item={item} items={items} onView={onView} />;
|
||||||
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
|
case "origin":
|
||||||
);
|
|
||||||
return (
|
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")}
|
{t(item.builtin ? "builtin" : "custom")}
|
||||||
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
|
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>
|
);
|
||||||
|
case "details":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
{values.kind === "field"
|
{values.kind === "field"
|
||||||
? t(values.type)
|
? t(values.type)
|
||||||
: t("fieldCount", { count: values.fields.length })}
|
: t("fieldCount", { count: values.fields.length })}
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>
|
);
|
||||||
|
case "state":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EntityStatus active={item.effectiveActive}>
|
||||||
{t(item.effectiveActive ? "active" : "inactive")}
|
{t(item.effectiveActive ? "active" : "inactive")}
|
||||||
|
</EntityStatus>
|
||||||
{scope !== "global" && !item.companyId && (
|
{scope !== "global" && !item.companyId && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(item.overridden ? "overridden" : "inherited")}
|
{t(item.overridden ? "overridden" : "inherited")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</>
|
||||||
<TableCell>
|
);
|
||||||
|
case "actions":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EntityRowActions label={t("actions")}>
|
||||||
<CatalogRowActions
|
<CatalogRowActions
|
||||||
editable={editable}
|
editable={editable}
|
||||||
item={item}
|
item={item}
|
||||||
@ -240,10 +300,11 @@ function CatalogRow({
|
|||||||
onView={onView}
|
onView={onView}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</EntityRowActions>
|
||||||
</TableRow>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function editorTitle(editor: Editor) {
|
function editorTitle(editor: Editor) {
|
||||||
if (editor.kind === "field") {
|
if (editor.kind === "field") {
|
||||||
@ -271,33 +332,51 @@ function CatalogRowActions({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("resources");
|
const { t } = useTranslation("resources");
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="contents">
|
||||||
<Button onClick={onView} size="sm" variant="outline">
|
<EntityAction onClick={onView}>{t("view")}</EntityAction>
|
||||||
{t("view")}
|
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
|
||||||
</Button>
|
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
|
||||||
{editable && (
|
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{t(item.effectiveActive ? "deactivate" : "activate")}
|
{t(item.effectiveActive ? "deactivate" : "activate")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
{item.overridden && (
|
{item.overridden && (
|
||||||
<Button disabled={pending} onClick={() => onStatus(null)} size="sm" variant="ghost">
|
<EntityAction disabled={pending} onClick={() => onStatus(null)}>
|
||||||
{t("reset")}
|
{t("reset")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 () => {
|
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 [
|
for (const [language, create, actions] of [
|
||||||
["es", "Nueva plantilla", "Crear copia"],
|
["es", "Nueva plantilla", "Acciones"],
|
||||||
["en", "New template", "Create copy"],
|
["en", "New template", "Actions"],
|
||||||
]) {
|
]) {
|
||||||
assert.ok(language && create && copy);
|
assert.ok(language && create && actions);
|
||||||
const html = await render(language, <ResourceCatalogPage csrfToken="test" scope="global" />);
|
const html = await render(language, <ResourceCatalogPage csrfToken="test" scope="global" />);
|
||||||
assert.ok(html.includes("Buzón de correo"));
|
assert.ok(html.includes("Buzón de correo"));
|
||||||
assert.ok(html.includes(create));
|
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("<form"), false);
|
||||||
assert.equal(html.includes(">Editar</button>"), false);
|
assert.equal(html.includes(">Editar</button>"), false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { 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 { useQuery } from "@tanstack/react-query";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
@ -24,8 +28,8 @@ export function ResourcesPage({
|
|||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
relatedContent?: (resource: ResourceDTO) => ReactNode;
|
relatedContent?: (resource: ResourceDTO) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("resources");
|
const { t, i18n } = useTranslation("resources");
|
||||||
const searchId = useId();
|
const _searchId = useId();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [editor, setEditor] = useState<string>();
|
const [editor, setEditor] = useState<string>();
|
||||||
const [viewId, setViewId] = useState<string>();
|
const [viewId, setViewId] = useState<string>();
|
||||||
@ -47,13 +51,20 @@ export function ResourcesPage({
|
|||||||
) ?? [];
|
) ?? [];
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<EntityListHeader
|
||||||
<h2 className="text-2xl font-semibold">{t("inventory")}</h2>
|
actions={
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
|
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
|
||||||
{t("newResource")}
|
{t("newResource")}
|
||||||
</Button>
|
</Button>{" "}
|
||||||
</div>
|
</>
|
||||||
<p className="text-sm text-muted-foreground">{t("resourceScope")}</p>
|
}
|
||||||
|
description={t("resourceScope")}
|
||||||
|
locale={i18n.language}
|
||||||
|
title={t("inventory")}
|
||||||
|
/>
|
||||||
|
|
||||||
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
|
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
|
||||||
<InventoryFeedback error={query.error ?? catalog.error} />
|
<InventoryFeedback error={query.error ?? catalog.error} />
|
||||||
{(query.isError || catalog.isError) && (
|
{(query.isError || catalog.isError) && (
|
||||||
@ -66,46 +77,68 @@ export function ResourcesPage({
|
|||||||
{t("retry")}
|
{t("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Card>
|
<EntityTable
|
||||||
<CardContent className="space-y-4 pt-6">
|
columns={[
|
||||||
<Label htmlFor={searchId}>{t("search")}</Label>
|
{
|
||||||
<Input id={searchId} onChange={(event) => setSearch(event.target.value)} value={search} />
|
id: "name",
|
||||||
{query.data &&
|
label: t("name"),
|
||||||
(visible.length ? (
|
value: (item) => item.values.name,
|
||||||
<div className="divide-y">
|
hideable: false,
|
||||||
{visible.map((item) => (
|
render: (item) => (
|
||||||
<div
|
<button
|
||||||
className="flex flex-wrap items-center justify-between gap-4 py-4"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
key={item.id}
|
onClick={() => setViewId(item.id)}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div>
|
{item.values.name}
|
||||||
<h3 className="font-medium">{item.values.name}</h3>
|
</button>
|
||||||
<p className="text-sm text-muted-foreground">
|
),
|
||||||
{item.specification.templateName} ·{" "}
|
},
|
||||||
|
{
|
||||||
|
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")}
|
{t(item.values.active ? "active" : "inactive")}
|
||||||
</p>
|
</EntityStatus>
|
||||||
</div>
|
),
|
||||||
<div className="flex gap-2">
|
},
|
||||||
<Button onClick={() => setViewId(item.id)} size="sm" variant="outline">
|
{
|
||||||
|
id: "actions",
|
||||||
|
label: t("actions"),
|
||||||
|
hideable: false,
|
||||||
|
render: (item) => (
|
||||||
|
<EntityRowActions label={t("actions")}>
|
||||||
|
{" "}
|
||||||
|
<div className="contents">
|
||||||
|
<EntityAction onClick={() => setViewId(item.id)}>
|
||||||
{t("viewResource")}
|
{t("viewResource")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
<Button
|
<EntityAction disabled={!catalog.data} onClick={() => setEditor(item.id)}>
|
||||||
disabled={!catalog.data}
|
|
||||||
onClick={() => setEditor(item.id)}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
</Button>
|
</EntityAction>
|
||||||
</div>
|
</div>{" "}
|
||||||
</div>
|
</EntityRowActions>
|
||||||
))}
|
),
|
||||||
</div>
|
},
|
||||||
) : (
|
]}
|
||||||
<p className="text-muted-foreground">{t("empty")}</p>
|
emptyMessage={t("empty")}
|
||||||
))}
|
items={visible}
|
||||||
</CardContent>
|
loading={query.isPending || catalog.isPending}
|
||||||
</Card>
|
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 && (
|
{editor && catalog.data && (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"ui:add": "shadcn add",
|
"ui:add": "shadcn add",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
@ -32,6 +32,7 @@
|
|||||||
"cn": "0.3.0",
|
"cn": "0.3.0",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"@base-ui/react": "1.8.0",
|
"@base-ui/react": "1.8.0",
|
||||||
"lucide-react": "1.8.0"
|
"lucide-react": "1.8.0",
|
||||||
|
"@tanstack/react-table": "8.21.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
packages/shadcn-ui/src/components/entity-list-controls.tsx
Normal file
140
packages/shadcn-ui/src/components/entity-list-controls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
packages/shadcn-ui/src/components/entity-list.tsx
Normal file
191
packages/shadcn-ui/src/components/entity-list.tsx
Normal 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);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
packages/shadcn-ui/src/components/entity-table-content.tsx
Normal file
98
packages/shadcn-ui/src/components/entity-table-content.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
packages/shadcn-ui/src/entity-list.test.tsx
Normal file
96
packages/shadcn-ui/src/entity-list.test.tsx
Normal 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"));
|
||||||
|
});
|
||||||
92
packages/shadcn-ui/src/hooks/use-entity-table.ts
Normal file
92
packages/shadcn-ui/src/hooks/use-entity-table.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
packages/shadcn-ui/src/lib/entity-list.ts
Executable file
62
packages/shadcn-ui/src/lib/entity-list.ts
Executable 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);
|
||||||
|
}
|
||||||
@ -457,6 +457,9 @@ importers:
|
|||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: 1.8.0
|
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)
|
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:
|
class-variance-authority:
|
||||||
specifier: 0.7.1
|
specifier: 0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user