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.
|
||||
Mantener accesibilidad y estados de carga, error, vacío y acciones deshabilitadas.
|
||||
Separar componentes por responsabilidades reales, sin fragmentar JSX trivial.
|
||||
Antes de crear o modificar pantallas, reconocer activamente patrones visuales y de interacción comunes entre ellas, así como componentes repetidos dentro de una misma pantalla. Aplicar soluciones coherentes a todos los casos equivalentes, conservando las necesidades propias de cada entidad.
|
||||
Reutilizar y extender la biblioteca visual existente antes de duplicar estructura, estilos o comportamiento. Extraer componentes compartidos cuando haya repetición real: cabeceras, listados, formularios modales, grupos de campos, selectores con alta, estados y ayuda contextual.
|
||||
Construir progresivamente una biblioteca visual básica con contratos claros, accesibilidad, textos traducibles y composición sencilla. Mantener los componentes genéricos independientes de entidades, DTO, consultas y endpoints de Metis Desk; el módulo consumidor aporta datos, textos y acciones. Documentar patrones y componentes en docs/componentes-de-interfaz.md y en el estándar correspondiente.
|
||||
Preparar esa independencia para poder agrupar o extraer posteriormente componentes en un paquete reutilizable en otras aplicaciones. Aprovechar @repo/shadcn-ui y crear abstracciones conforme aparezcan consumidores reales.
|
||||
Las pantallas de listado de entidades siguen [el patrón de listados](docs/patron-de-listados-de-entidades.md), reutilizando los componentes comunes de @repo/shadcn-ui.
|
||||
Los listados no muestran formularios de alta ni edición junto a los datos. El alta se inicia con un botón en la cabecera, a la derecha; formularios en modal shadcn/ui como norma general, con anchura y desplazamiento adaptados a su contenido. La edición se abre mediante una acción explícita, siguiendo el mismo criterio. Mantener foco, cierre por teclado, cancelación y errores dentro del formulario. Incluir ayuda contextual con finalidad, ámbito y ejemplos, sin rellenar los campos automáticamente. En formularios, colocar la ayuda en la parte inferior, inmediatamente antes del pie del modal con Guardar y Cancelar.
|
||||
|
||||
## Validación y entrega
|
||||
|
||||
@ -117,6 +117,8 @@ body {
|
||||
}
|
||||
.asset-side-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow-wrap: anywhere;
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
min-width: 0;
|
||||
|
||||
@ -89,3 +89,15 @@ Uso de cuenta, estado de configuración e idioma son listas cerradas. Las refere
|
||||
El catálogo de activos utiliza SelectWithAction para añadir campos a una plantilla y crearlos mediante un modal adicional. Los tipos de dato constituyen una lista cerrada. Las definiciones de serie se consultan en modales de lectura; su personalización se realiza mediante copias.
|
||||
|
||||
El inventario añade altas rápidas de plantilla, sede y persona en los activos. La plantilla creada desde un activo solo ofrece campos existentes para mantener un único nivel adicional de modal. Las características utilizan controles según su tipo; los valores sí/no conservan un estado vacío distinto de No.
|
||||
|
||||
## Listados de entidades
|
||||
|
||||
El estándar se define en [Patrón de listados de entidades](patron-de-listados-de-entidades.md), con inventario de pantallas y excepciones. EntityListHeader, EntityTable, EntityRowActions, EntityAction y EntityStatus viven en @repo/shadcn-ui/components/entity-list.
|
||||
|
||||
EntityTable recibe datos y columnas tipadas; utiliza TanStack Table para búsqueda, ordenación, visibilidad y paginación local. Admite búsqueda y filtros controlados por el módulo, conservando su semántica y ámbito. Las columnas de identificación y acciones quedan visibles. Los menús utilizan Base UI y conservan los callbacks de las acciones originales. No realiza peticiones ni conoce DTO de negocio.
|
||||
|
||||
La paginación es local sobre los conjuntos que las API actuales ya devuelven. No simula paginación de servidor. La selección del activo para el panel sigue perteneciendo a Inventario, fuera de la tabla compartida.
|
||||
|
||||
EntityTable separa internamente la configuración de columnas/ordenación, el contenido semántico y la paginación; useEntityTable concentra el estado de representación. entityLabel permite contar «plantillas», «campos» o «tickets» aunque el título del espacio sea más amplio. Los tamaños disponibles son 10, 25, 50 y 100; una opción vacía nunca cambia el tamaño a cero.
|
||||
|
||||
Se declara @tanstack/react-table en el paquete compartido porque es quien consume ahora la librería; se reutiliza la versión 8.21.3 que ya utilizaba Compañías. No se introduce un segundo sistema de tablas.
|
||||
|
||||
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">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="font-semibold">{t("support.title")}</h3>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => setToolsOpen(true)} size="sm" variant="outline">
|
||||
{t("support.tools")}
|
||||
</Button>
|
||||
@ -88,7 +88,7 @@ export function AssetSupport({
|
||||
(retired || !tools.find((tool) => tool.id === item.values.toolId)?.active) && (
|
||||
<p className="text-sm text-amber-700">{t("support.review")}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(item);
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -29,8 +25,8 @@ type Editor = {
|
||||
copy?: CatalogItemDTO;
|
||||
};
|
||||
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
||||
const { t } = useTranslation("assets");
|
||||
const searchId = useId();
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const _searchId = useId();
|
||||
const client = useQueryClient();
|
||||
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
|
||||
const [search, setSearch] = useState("");
|
||||
@ -56,19 +52,18 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{t(scope === "global" ? "title" : "companyTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||
</p>
|
||||
</div>
|
||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
|
||||
</Button>
|
||||
</div>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||
locale={i18n.language}
|
||||
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||
/>
|
||||
<nav aria-label={t("title")} className="flex gap-2">
|
||||
{(["template", "field", "relation"] as const).map((kind) => (
|
||||
<Button
|
||||
@ -97,51 +92,111 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={searchId}>{t("search")}</Label>
|
||||
<Input
|
||||
id={searchId}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
{query.data &&
|
||||
(visible.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("origin")}</TableHead>
|
||||
<TableHead>
|
||||
{t({ field: "type", template: "fields", relation: "inverseName" }[tab])}
|
||||
</TableHead>
|
||||
<TableHead>{t("active")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map((item) => (
|
||||
<CatalogRow
|
||||
item={item}
|
||||
items={items}
|
||||
key={item.id}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t("empty")}</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (item) => item.values.name,
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="name"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "origin",
|
||||
label: t("origin"),
|
||||
value: (item) => t(item.builtin ? "builtin" : "custom"),
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="origin"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "details",
|
||||
label: t({ field: "type", template: "fields", relation: "inverseName" }[tab]),
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="details"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("active"),
|
||||
value: (item) => item.effectiveActive,
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="state"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="actions"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("empty")}
|
||||
entityLabel={t({ field: "fields", template: "templates", relation: "relations" }[tab])}
|
||||
items={visible}
|
||||
key={tab}
|
||||
loading={query.isPending}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||
unavailable={!query.data && query.isError}
|
||||
/>
|
||||
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
|
||||
{editor && (
|
||||
<FormDialog
|
||||
@ -185,7 +240,8 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function CatalogRow({
|
||||
function CatalogCell({
|
||||
column,
|
||||
item,
|
||||
scope,
|
||||
items,
|
||||
@ -195,6 +251,7 @@ function CatalogRow({
|
||||
onView,
|
||||
onStatus,
|
||||
}: {
|
||||
column: "name" | "origin" | "details" | "state" | "actions";
|
||||
item: CatalogItemDTO;
|
||||
scope: string;
|
||||
items: readonly CatalogItemDTO[];
|
||||
@ -206,55 +263,49 @@ function CatalogRow({
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
|
||||
const values = item.values;
|
||||
const inactiveFields =
|
||||
values.kind === "template" &&
|
||||
values.fields.some(
|
||||
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
|
||||
);
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<p className="font-medium">{values.name}</p>
|
||||
{values.description && (
|
||||
<p className="text-xs text-muted-foreground">{values.description}</p>
|
||||
)}
|
||||
{inactiveFields && (
|
||||
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t(item.builtin ? "builtin" : "custom")}
|
||||
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{values.kind === "field"
|
||||
? t(values.type)
|
||||
: values.kind === "relation"
|
||||
? values.inverseName
|
||||
: t("fieldCount", { count: values.fields.length })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t(item.effectiveActive ? "active" : "inactive")}
|
||||
{scope !== "global" && !item.companyId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(item.overridden ? "overridden" : "inherited")}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CatalogRowActions
|
||||
editable={editable}
|
||||
item={item}
|
||||
onCopy={onCopy}
|
||||
onEdit={onEdit}
|
||||
onStatus={onStatus}
|
||||
onView={onView}
|
||||
pending={pending}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
const _values = item.values;
|
||||
switch (column) {
|
||||
case "name":
|
||||
return <CatalogName item={item} items={items} onView={onView} />;
|
||||
case "origin":
|
||||
return (
|
||||
<>
|
||||
{t(item.builtin ? "builtin" : "custom")}
|
||||
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
|
||||
</>
|
||||
);
|
||||
case "details":
|
||||
return <CatalogDetails item={item} />;
|
||||
case "state":
|
||||
return (
|
||||
<>
|
||||
<EntityStatus active={item.effectiveActive}>
|
||||
{t(item.effectiveActive ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
{scope !== "global" && !item.companyId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(item.overridden ? "overridden" : "inherited")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<>
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<CatalogRowActions
|
||||
editable={editable}
|
||||
item={item}
|
||||
onCopy={onCopy}
|
||||
onEdit={onEdit}
|
||||
onStatus={onStatus}
|
||||
onView={onView}
|
||||
pending={pending}
|
||||
/>
|
||||
</EntityRowActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function editorTitle(editor: Editor) {
|
||||
@ -286,33 +337,63 @@ function CatalogRowActions({
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={onView} size="sm" variant="outline">
|
||||
{t("view")}
|
||||
</Button>
|
||||
{editable && (
|
||||
<Button onClick={onEdit} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
{item.values.kind === "template" && (
|
||||
<Button onClick={onCopy} size="sm" variant="outline">
|
||||
{t("copy")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => onStatus(!item.effectiveActive)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="contents">
|
||||
<EntityAction onClick={onView}>{t("view")}</EntityAction>
|
||||
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
|
||||
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
|
||||
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>
|
||||
{t(item.effectiveActive ? "deactivate" : "activate")}
|
||||
</Button>
|
||||
</EntityAction>
|
||||
{item.overridden && (
|
||||
<Button disabled={pending} onClick={() => onStatus(null)} size="sm" variant="ghost">
|
||||
<EntityAction disabled={pending} onClick={() => onStatus(null)}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
</EntityAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogName({
|
||||
item,
|
||||
items,
|
||||
onView,
|
||||
}: {
|
||||
item: CatalogItemDTO;
|
||||
items: readonly CatalogItemDTO[];
|
||||
onView: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const values = item.values;
|
||||
const inactiveFields =
|
||||
values.kind === "template" &&
|
||||
values.fields.some(
|
||||
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={onView}
|
||||
type="button"
|
||||
>
|
||||
{values.name}
|
||||
</button>
|
||||
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}
|
||||
{inactiveFields && (
|
||||
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogDetails({ item }: { item: CatalogItemDTO }) {
|
||||
const { t } = useTranslation("assets");
|
||||
const values = item.values;
|
||||
if (values.kind === "field") {
|
||||
return t(values.type);
|
||||
}
|
||||
if (values.kind === "relation") {
|
||||
return values.inverseName;
|
||||
}
|
||||
return t("fieldCount", { count: values.fields.length });
|
||||
}
|
||||
|
||||
@ -40,15 +40,15 @@ async function render(language: string, children: ReactNode) {
|
||||
}
|
||||
}
|
||||
test("el catálogo muestra plantillas de serie y acción de alta sin formularios junto al listado", async () => {
|
||||
for (const [language, create, copy] of [
|
||||
["es", "Nueva plantilla", "Crear copia"],
|
||||
["en", "New template", "Create copy"],
|
||||
for (const [language, create, actions] of [
|
||||
["es", "Nueva plantilla", "Acciones"],
|
||||
["en", "New template", "Actions"],
|
||||
]) {
|
||||
assert.ok(language && create && copy);
|
||||
assert.ok(language && create && actions);
|
||||
const html = await render(language, <AssetCatalogPage csrfToken="test" scope="global" />);
|
||||
assert.ok(html.includes("Portátil"));
|
||||
assert.ok(html.includes(create));
|
||||
assert.ok(html.includes(copy));
|
||||
assert.ok(html.includes(actions));
|
||||
assert.equal(html.includes("<form"), false);
|
||||
assert.equal(html.includes(">Editar</button>"), false);
|
||||
}
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -25,7 +30,7 @@ export function AssetInventoryPage({
|
||||
csrfToken: string;
|
||||
lookups: InventoryLookups;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const query = useQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => inventoryApi.get(companyId),
|
||||
@ -57,29 +62,30 @@ export function AssetInventoryPage({
|
||||
return (
|
||||
<div className="inventory-layout" data-detail-open={detailOpen && Boolean(selected)}>
|
||||
<div className="inventory-list space-y-5">
|
||||
<header className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("inventory")}</h2>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{!detailOpen && (
|
||||
<Button
|
||||
className="asset-desktop-action"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("showAssetPanel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
|
||||
{t("newAsset")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{!detailOpen && (
|
||||
<Button
|
||||
className="asset-desktop-action"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("showAssetPanel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
|
||||
{t("newAsset")}
|
||||
</Button>
|
||||
</div>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("inventory")}
|
||||
/>
|
||||
|
||||
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={query.error} />
|
||||
<CatalogError error={catalog.error} />
|
||||
@ -94,68 +100,103 @@ export function AssetInventoryPage({
|
||||
</Button>
|
||||
)}
|
||||
{query.data && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(assets.length ? "noResults" : "noAssets")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((asset) => (
|
||||
<div
|
||||
className={`inventory-asset-row flex flex-wrap items-center justify-between gap-3 py-4 ${selectedRowClass(asset.id, selected?.id, detailOpen)}`}
|
||||
key={asset.id}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
<button
|
||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
||||
className="inventory-asset-name"
|
||||
onClick={() => {
|
||||
setSelectedId(asset.id);
|
||||
setDetailOpen(true);
|
||||
if (window.matchMedia("(max-width: 1099px)").matches) {
|
||||
setViewId(asset.id);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (asset) => asset.values.name,
|
||||
hideable: false,
|
||||
render: (asset) => (
|
||||
<button
|
||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
||||
className="inventory-asset-name text-left font-medium"
|
||||
onClick={() => {
|
||||
setSelectedId(asset.id);
|
||||
setDetailOpen(true);
|
||||
if (window.matchMedia("(max-width: 1099px)").matches) {
|
||||
setViewId(asset.id);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{asset.values.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "template",
|
||||
label: t("template"),
|
||||
value: (asset) => asset.specification.templateName,
|
||||
render: (asset) => <>{asset.specification.templateName}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (asset) => t(asset.values.status),
|
||||
render: (asset) => (
|
||||
<EntityStatus active={asset.values.status !== "retired"}>
|
||||
{t(asset.values.status)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
label: t("site"),
|
||||
value: (asset) =>
|
||||
lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||
t("noAssignment"),
|
||||
render: (asset) => (
|
||||
<>
|
||||
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||
t("noAssignment")}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "person",
|
||||
label: t("person"),
|
||||
value: (asset) =>
|
||||
lookups.people.find((person) => person.id === asset.values.personId)?.name ??
|
||||
t("noAssignment"),
|
||||
render: (asset) => (
|
||||
<>
|
||||
{lookups.people.find((person) => person.id === asset.values.personId)?.name ??
|
||||
t("noAssignment")}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (asset) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction
|
||||
className="asset-mobile-action"
|
||||
onClick={() => setViewId(asset.id)}
|
||||
>
|
||||
{asset.values.name}
|
||||
</button>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{asset.specification.templateName} · {t(asset.values.status)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||
t("noAssignment")}{" "}
|
||||
·{" "}
|
||||
{lookups.people.find((person) => person.id === asset.values.personId)?.name ??
|
||||
t("noAssignment")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="asset-mobile-action"
|
||||
onClick={() => setViewId(asset.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("viewAsset")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!catalog.data}
|
||||
onClick={() => setEditor(asset.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{t("viewAsset")}
|
||||
</EntityAction>
|
||||
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("noAssets")}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t("inventory")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AssetSelectionPanel
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
@ -9,22 +16,29 @@ import type { SupportToolDTO } from "../common/support";
|
||||
import { supportApi, supportKeys } from "./support-api";
|
||||
import { SupportToolForm } from "./support-tool-form";
|
||||
export function SupportTools({ csrfToken }: { csrfToken: string }) {
|
||||
const { t } = useTranslation("assets");
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const query = useQuery({ queryKey: supportKeys.tools, queryFn: () => supportApi.tools() });
|
||||
const [edit, setEdit] = useState<SupportToolDTO | undefined>();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("support.newTool")}
|
||||
</Button>
|
||||
</header>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("support.newTool")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("support.tools")}
|
||||
/>
|
||||
{query.isPending && <p role="status">{t("support.loading")}</p>}
|
||||
{query.isError && (
|
||||
<div role="alert">
|
||||
@ -39,25 +53,55 @@ export function SupportTools({ csrfToken }: { csrfToken: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y">
|
||||
{query.data?.tools.map((tool) => (
|
||||
<li className="flex items-center justify-between gap-3 py-3" key={tool.id}>
|
||||
<span>
|
||||
{tool.name} · {t(tool.active ? "support.active" : "inactive")}
|
||||
{tool.builtin ? ` · ${t("support.builtin")}` : ""}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(tool);
|
||||
setOpen(true);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (tool) => tool.name,
|
||||
hideable: false,
|
||||
render: (tool) => <span className="font-medium">{tool.name}</span>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (tool) => tool.active,
|
||||
render: (tool) => (
|
||||
<EntityStatus active={tool.active}>
|
||||
{t(tool.active ? "support.active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "origin",
|
||||
label: t("origin"),
|
||||
value: (tool) => tool.builtin,
|
||||
render: (tool) => <>{t(tool.builtin ? "support.builtin" : "custom")}</>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (tool) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<EntityAction
|
||||
onClick={() => {
|
||||
setEdit(tool);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
items={query.data?.tools ?? []}
|
||||
loading={query.isPending}
|
||||
locale={i18n.language}
|
||||
title={t("support.tools")}
|
||||
unavailable={query.isError}
|
||||
/>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -44,7 +49,7 @@ export function WorkstationsPage({
|
||||
(value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
)
|
||||
);
|
||||
const summary = (item: WorkstationDTO) => (
|
||||
const _summary = (item: WorkstationDTO) => (
|
||||
<>
|
||||
<h3 className="font-medium">
|
||||
{item.values.name} · {t(item.values.active ? "active" : "inactive")}
|
||||
@ -66,18 +71,19 @@ export function WorkstationsPage({
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("workstations")}</h2>
|
||||
<Button disabled={!query.data} onClick={() => setEditor("new")}>
|
||||
{t("newWorkstation")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!query.data} onClick={() => setEditor("new")}>
|
||||
{t("newWorkstation")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("workstations")}
|
||||
/>
|
||||
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
@ -91,28 +97,94 @@ export function WorkstationsPage({
|
||||
</Button>
|
||||
)}
|
||||
{query.data && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(stations.length ? "noResults" : "noWorkstations")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
|
||||
<div className="min-w-0 break-words">{summary(item)}</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("workstationHistory")}
|
||||
</Button>
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (item) => item.values.name,
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{item.values.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
label: t("site"),
|
||||
value: (item) => siteName(item.values.siteId),
|
||||
render: (item) => <>{siteName(item.values.siteId)}</>,
|
||||
},
|
||||
{
|
||||
id: "person",
|
||||
label: t("person"),
|
||||
value: (item) => personName(item.values.personId),
|
||||
render: (item) => <>{personName(item.values.personId)}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(item.values.active ? "active" : "inactive"),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.active}>
|
||||
{t(item.values.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "exclusive",
|
||||
label: t("exclusiveWorkstationAssets"),
|
||||
render: (item) => <>{members(item.values.assetIds)}</>,
|
||||
},
|
||||
{
|
||||
id: "shared",
|
||||
label: t("sharedWorkstationAssets"),
|
||||
render: (item) => (
|
||||
<>{members(item.values.sharedAssetIds, "noSharedWorkstationAssets")}</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "count",
|
||||
label: t("characteristics"),
|
||||
value: (item) => item.values.assetIds.length + item.values.sharedAssetIds.length,
|
||||
render: (item) => (
|
||||
<>
|
||||
{t("workstationCount", {
|
||||
count: item.values.assetIds.length + item.values.sharedAssetIds.length,
|
||||
})}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setView(item.id)}>
|
||||
{t("workstationHistory")}
|
||||
</EntityAction>
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("noWorkstations")}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t("workstations")}
|
||||
/>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { TableCell, TableRow } from "@repo/shadcn-ui/components/table";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AccessFieldsDTO, AccessItemDTO, AccessResponseDTO, PersonDTO } from "../../common";
|
||||
@ -17,7 +20,11 @@ function EntryState({ value }: { value: AccessFieldsDTO }) {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<p>{state}</p>
|
||||
<EntityStatus
|
||||
active={"active" in value ? value.active : "endDate" in value && !value.endDate}
|
||||
>
|
||||
{state}
|
||||
</EntityStatus>
|
||||
{"configuration" in value && value.configuration && (
|
||||
<p className="text-sm">{t(value.configuration)}</p>
|
||||
)}
|
||||
@ -35,7 +42,8 @@ function EntryState({ value }: { value: AccessFieldsDTO }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function AccessRow({
|
||||
export function AccessCell({
|
||||
column,
|
||||
item,
|
||||
data,
|
||||
scope,
|
||||
@ -43,6 +51,7 @@ export function AccessRow({
|
||||
onEdit,
|
||||
onHistory,
|
||||
}: {
|
||||
column: "name" | "state" | "review" | "actions";
|
||||
item: AccessItemDTO;
|
||||
data: AccessResponseDTO;
|
||||
scope: string;
|
||||
@ -60,40 +69,55 @@ export function AccessRow({
|
||||
return permission?.kind === "permission" ? permission.name : "—";
|
||||
})
|
||||
.join(", ");
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<p className="font-medium">{accessLabel(item, data.items, people)}</p>
|
||||
{!own && <p className="text-xs text-muted-foreground">{t("global")}</p>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EntryState value={item.values} />
|
||||
{coverage && <p className="text-sm">{t(coverage.covered ? "covered" : "notCovered")}</p>}
|
||||
{coverage && !coverage.covered && missing && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("missingPermissions")}: {missing}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{reviews.length
|
||||
? reviews.map((review) => (
|
||||
<p className="text-sm" key={review.reason}>
|
||||
{t(`reviews.${review.reason}`)}
|
||||
</p>
|
||||
))
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={!own} onClick={onEdit} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button onClick={onHistory} size="sm" variant="ghost">
|
||||
{t("history")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
switch (column) {
|
||||
case "name":
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
<p className="font-medium">{accessLabel(item, data.items, people)}</p>
|
||||
{!own && <p className="text-xs text-muted-foreground">{t("global")}</p>}{" "}
|
||||
</>
|
||||
);
|
||||
case "state":
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
<EntryState value={item.values} />
|
||||
{coverage && <p className="text-sm">{t(coverage.covered ? "covered" : "notCovered")}</p>}
|
||||
{coverage && !coverage.covered && missing && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("missingPermissions")}: {missing}
|
||||
</p>
|
||||
)}{" "}
|
||||
</>
|
||||
);
|
||||
case "review":
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
{reviews.length
|
||||
? reviews.map((review) => (
|
||||
<p className="text-sm" key={review.reason}>
|
||||
{t(`reviews.${review.reason}`)}
|
||||
</p>
|
||||
))
|
||||
: "—"}{" "}
|
||||
</>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction disabled={!own} onClick={onEdit}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
<EntityAction onClick={onHistory}>{t("history")}</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>{" "}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,6 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -21,7 +12,7 @@ import { accessFields, createLabels } from "./access-fields";
|
||||
import { AccessForm } from "./access-form";
|
||||
import { AccessHistory } from "./access-history";
|
||||
import { accessLabel } from "./access-labels";
|
||||
import { AccessRow } from "./access-row";
|
||||
import { AccessCell } from "./access-row";
|
||||
import { EntityHelp } from "./entity-help";
|
||||
|
||||
export function AccessWorkspacePage({
|
||||
@ -33,7 +24,7 @@ export function AccessWorkspacePage({
|
||||
people?: readonly PersonDTO[];
|
||||
csrfToken: string;
|
||||
}) {
|
||||
const { t } = useTranslation("access");
|
||||
const { t, i18n } = useTranslation("access");
|
||||
const query = useQuery({
|
||||
queryKey: accessKeys.scope(scope),
|
||||
queryFn: () => accessApi.request(scope),
|
||||
@ -43,7 +34,7 @@ export function AccessWorkspacePage({
|
||||
const [history, setHistory] = useState<string>();
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchId = useId();
|
||||
const _searchId = useId();
|
||||
const data = query.data;
|
||||
const selected = data?.items.find((item) => item.id === editor);
|
||||
const historical = data?.items.find((item) => item.id === history);
|
||||
@ -63,27 +54,35 @@ export function AccessWorkspacePage({
|
||||
"resource_type",
|
||||
"permission",
|
||||
];
|
||||
const visible =
|
||||
data?.items.filter(
|
||||
(item) =>
|
||||
item.values.kind === kind &&
|
||||
accessLabel(item, data.items, people)
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.trim().toLocaleLowerCase())
|
||||
) ?? [];
|
||||
const complex = accessFields[kind].length > 5;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t(scope === "global" ? "catalogTitle" : "title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setHelpOpen(true)} variant="outline">
|
||||
{t("helpButton")}
|
||||
</Button>
|
||||
<Button disabled={!data} onClick={() => setEditor("new")}>
|
||||
{t(createLabels[kind])}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setHelpOpen(true)} variant="outline">
|
||||
{t("helpButton")}
|
||||
</Button>
|
||||
<Button disabled={!data} onClick={() => setEditor("new")}>
|
||||
{t(createLabels[kind])}
|
||||
</Button>
|
||||
</div>{" "}
|
||||
</>
|
||||
}
|
||||
description={t(scope === "global" ? "catalogHelp" : "workspaceHelp")}
|
||||
locale={i18n.language}
|
||||
title={t(`kinds.${kind}`)}
|
||||
/>
|
||||
<FormDialog
|
||||
cancelLabel={t("helpClose")}
|
||||
onOpenChange={setHelpOpen}
|
||||
@ -125,52 +124,81 @@ export function AccessWorkspacePage({
|
||||
)}
|
||||
{data && (
|
||||
<>
|
||||
<div className="max-w-sm space-y-2">
|
||||
<Label htmlFor={searchId}>{t("search")}</Label>
|
||||
<Input
|
||||
id={searchId}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("state")}</TableHead>
|
||||
<TableHead>{t("review")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.values.kind === kind &&
|
||||
accessLabel(item, data.items, people)
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.trim().toLocaleLowerCase())
|
||||
)
|
||||
.map((item) => (
|
||||
<AccessRow
|
||||
data={data}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onEdit={() => setEditor(item.id)}
|
||||
onHistory={() => setHistory(item.id)}
|
||||
people={people}
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{!data.items.some((item) => item.values.kind === kind) && (
|
||||
<p className="py-4 text-muted-foreground">{t("empty")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (item) => accessLabel(item, data.items, people),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<AccessCell
|
||||
column="name"
|
||||
data={data}
|
||||
item={item}
|
||||
onEdit={() => setEditor(item.id)}
|
||||
onHistory={() => setHistory(item.id)}
|
||||
people={people}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("state"),
|
||||
render: (item) => (
|
||||
<AccessCell
|
||||
column="state"
|
||||
data={data}
|
||||
item={item}
|
||||
onEdit={() => setEditor(item.id)}
|
||||
onHistory={() => setHistory(item.id)}
|
||||
people={people}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: t("review"),
|
||||
render: (item) => (
|
||||
<AccessCell
|
||||
column="review"
|
||||
data={data}
|
||||
item={item}
|
||||
onEdit={() => setEditor(item.id)}
|
||||
onHistory={() => setHistory(item.id)}
|
||||
people={people}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<AccessCell
|
||||
column="actions"
|
||||
data={data}
|
||||
item={item}
|
||||
onEdit={() => setEditor(item.id)}
|
||||
onHistory={() => setHistory(item.id)}
|
||||
people={people}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("empty")}
|
||||
items={visible}
|
||||
key={kind}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t(`kinds.${kind}`)}
|
||||
/>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
|
||||
@ -4,9 +4,14 @@ import {
|
||||
inventoryKeys as resourceKeys,
|
||||
} from "@metis/resources/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -26,7 +31,7 @@ export function ApplicationConfigurationsPage({
|
||||
csrfToken: string;
|
||||
initialProfileId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
@ -71,18 +76,19 @@ export function ApplicationConfigurationsPage({
|
||||
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("applications.title")}</h2>
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
{t("applications.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("applications.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("applications.search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
{t("applications.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("applications.title")}
|
||||
/>
|
||||
|
||||
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
@ -105,42 +111,84 @@ export function ApplicationConfigurationsPage({
|
||||
</Button>
|
||||
)}
|
||||
{ready && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">{t("applications.empty")}</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
|
||||
<div>
|
||||
<h3 className="font-medium">{resource(item.values.resourceId)}</h3>
|
||||
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
|
||||
<p className="text-sm">
|
||||
{t(`applications.${item.values.delivery}`)} · {t(`mail.${item.values.state}`)}
|
||||
</p>
|
||||
{item.values.version && (
|
||||
<p className="text-sm">
|
||||
{t("applications.versionValue", { version: item.values.version })}
|
||||
</p>
|
||||
)}
|
||||
{item.needsReview && (
|
||||
<p className="text-sm text-amber-700">{t("applications.review")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("mail.history")}
|
||||
</Button>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("applications.title"),
|
||||
value: (item) => resource(item.values.resourceId),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{resource(item.values.resourceId)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
label: t("profiles.title"),
|
||||
value: (item) => label(item.values.profileId),
|
||||
render: (item) => <>{label(item.values.profileId)}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(`mail.${item.values.state}`),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.state === "configured"}>
|
||||
{t(`mail.${item.values.state}`)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "delivery",
|
||||
label: t("applications.delivery"),
|
||||
value: (item) => t(`applications.${item.values.delivery}`),
|
||||
render: (item) => <>{t(`applications.${item.values.delivery}`)}</>,
|
||||
},
|
||||
{
|
||||
id: "version",
|
||||
label: t("applications.version"),
|
||||
value: (item) => item.values.version,
|
||||
render: (item) => <>{item.values.version || "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: t("profiles.review"),
|
||||
value: (item) => item.needsReview,
|
||||
render: (item) => <>{item.needsReview ? t("applications.review") : "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setView(item.id)}>
|
||||
{t("mail.history")}
|
||||
</EntityAction>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("applications.empty")}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("applications.search")}
|
||||
title={t("applications.title")}
|
||||
/>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
|
||||
@ -66,7 +66,7 @@ test("aplicaciones: listado sin formularios, alta en cabecera y traducciones", a
|
||||
assert.ok(html.includes(title ?? ""));
|
||||
assert.ok(html.includes(action ?? ""));
|
||||
assert.ok(!html.includes("<form"));
|
||||
assert.ok(!html.includes("applications."));
|
||||
assert.equal(/(?:>|")applications\./.test(html), false);
|
||||
}
|
||||
});
|
||||
test("aplicaciones: selectores con alta rápida y ayuda inferior antes de guardar", async () => {
|
||||
@ -81,6 +81,6 @@ test("aplicaciones: selectores con alta rápida y ayuda inferior antes de guarda
|
||||
assert.ok(html.includes(text.applications.newResource));
|
||||
assert.ok(html.indexOf(text.applications.help) > html.indexOf(text.applications.delivery));
|
||||
assert.ok(html.indexOf(text.save) > html.indexOf(text.applications.help));
|
||||
assert.ok(!html.includes("applications."));
|
||||
assert.equal(/(?:>|")applications\./.test(html), false);
|
||||
}
|
||||
});
|
||||
|
||||
@ -4,9 +4,14 @@ import {
|
||||
inventoryKeys as resourceKeys,
|
||||
} from "@metis/resources/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -71,18 +76,19 @@ export function CertificateConfigurationsPage({
|
||||
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("certificates.title")}</h2>
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
{t("certificates.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("certificates.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("certificates.search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
{t("certificates.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("certificates.title")}
|
||||
/>
|
||||
|
||||
{(query.isPending || profiles.isPending || inventory.isPending || resources.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
@ -105,51 +111,91 @@ export function CertificateConfigurationsPage({
|
||||
</Button>
|
||||
)}
|
||||
{ready && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">{t("certificates.empty")}</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
|
||||
<div>
|
||||
<h3 className="font-medium">{resource(item.values.resourceId)}</h3>
|
||||
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
|
||||
<p className="text-sm">{t(`mail.${item.values.state}`)}</p>
|
||||
{item.values.location && (
|
||||
<p className="text-sm">
|
||||
{t("certificates.locationValue", { location: item.values.location })}
|
||||
</p>
|
||||
)}
|
||||
{item.expiresOn && (
|
||||
<p className="text-sm">
|
||||
{t("certificates.expiryValue", {
|
||||
date: new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(item.expiresOn)),
|
||||
})}
|
||||
{item.expired ? ` · ${t("certificates.expired")}` : ""}
|
||||
</p>
|
||||
)}
|
||||
{item.needsReview && (
|
||||
<p className="text-sm text-amber-700">{t("certificates.review")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("mail.history")}
|
||||
</Button>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("certificates.title"),
|
||||
value: (item) => resource(item.values.resourceId),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{resource(item.values.resourceId)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
label: t("profiles.title"),
|
||||
value: (item) => label(item.values.profileId),
|
||||
render: (item) => <>{label(item.values.profileId)}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(`mail.${item.values.state}`),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.state === "configured"}>
|
||||
{t(`mail.${item.values.state}`)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
label: t("certificates.location"),
|
||||
value: (item) => item.values.location,
|
||||
render: (item) => <>{item.values.location || "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "expiry",
|
||||
label: t("telework.expiresOn"),
|
||||
value: (item) => item.expiresOn,
|
||||
render: (item) => (
|
||||
<>
|
||||
{item.expiresOn
|
||||
? new Intl.DateTimeFormat(i18n.language).format(new Date(item.expiresOn))
|
||||
: "—"}
|
||||
{item.expired ? ` · ${t("certificates.expired")}` : ""}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: t("profiles.review"),
|
||||
value: (item) => item.needsReview,
|
||||
render: (item) => <>{item.needsReview ? t("certificates.review") : "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setView(item.id)}>
|
||||
{t("mail.history")}
|
||||
</EntityAction>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("certificates.empty")}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("certificates.search")}
|
||||
title={t("certificates.title")}
|
||||
/>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
|
||||
@ -37,12 +33,12 @@ export function CompaniesPage({
|
||||
csrfToken: string;
|
||||
onSelectCompany?: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const id = useId();
|
||||
const _id = useId();
|
||||
const table = useReactTable({
|
||||
data: query.data?.companies ?? EMPTY_COMPANIES,
|
||||
columns: COMPANY_COLUMNS,
|
||||
@ -65,23 +61,18 @@ export function CompaniesPage({
|
||||
const companies = table.getRowModel().rows.map((row) => row.original);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t("help")}</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>{t("newCompany")}</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("search")}</Label>
|
||||
<Input
|
||||
className="max-w-md"
|
||||
id={id}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button onClick={() => setCreateOpen(true)}>{t("newCompany")}</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("help")}
|
||||
locale={i18n.language}
|
||||
title={t("title")}
|
||||
/>
|
||||
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<CompanyFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
@ -95,40 +86,52 @@ export function CompaniesPage({
|
||||
</Button>
|
||||
)}
|
||||
{query.data && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{companies.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t(search ? "noResults" : "empty")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{companies.map((company) => (
|
||||
<TableRow key={company.id}>
|
||||
<TableCell className="font-medium">{company.name}</TableCell>
|
||||
<TableCell>{t(company.active ? "active" : "inactive")}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
onClick={() => selectCompany(company.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("open")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (company) => company.name,
|
||||
hideable: false,
|
||||
render: (company) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => selectCompany(company.id)}
|
||||
type="button"
|
||||
>
|
||||
{company.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (company) => t(company.active ? "active" : "inactive"),
|
||||
render: (company) => (
|
||||
<EntityStatus active={company.active}>
|
||||
{t(company.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (company) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<EntityAction onClick={() => selectCompany(company.id)}>{t("open")}</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("empty")}
|
||||
items={companies}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
title={t("title")}
|
||||
/>
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { AssetCatalogPage } from "@metis/assets/client";
|
||||
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import {
|
||||
BoxIcon,
|
||||
@ -43,39 +48,50 @@ import { PeopleSection } from "./people-section";
|
||||
import { SiteForm } from "./site-form";
|
||||
|
||||
function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
const selected = company.sites.find((site) => site.id === selectedId);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{t("sites")}</h2>
|
||||
<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>
|
||||
</div>
|
||||
{company.sites.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("noSites")}</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{company.sites.map((site) => (
|
||||
<div className="flex items-start justify-between gap-4 py-4" key={site.id}>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium">{site.name}</h3>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||
{site.address ?? t("noAddress")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSelectedId(site.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityListHeader
|
||||
actions={<Button onClick={() => setCreateOpen(true)}>{t("newSite")}</Button>}
|
||||
locale={i18n.language}
|
||||
title={t("sites")}
|
||||
/>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (site) => site.name,
|
||||
hideable: false,
|
||||
render: (site) => <span className="font-medium">{site.name}</span>,
|
||||
},
|
||||
{
|
||||
id: "address",
|
||||
label: t("addressOptional"),
|
||||
value: (site) => site.address,
|
||||
render: (site) => (
|
||||
<span className="whitespace-pre-wrap">{site.address ?? t("noAddress")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (site) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<EntityAction onClick={() => setSelectedId(site.id)}>{t("edit")}</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("noSites")}
|
||||
items={company.sites}
|
||||
locale={i18n.language}
|
||||
title={t("sites")}
|
||||
/>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@ -106,70 +104,88 @@ export function DepartmentSection({
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string | undefined>();
|
||||
const selected = company.departments.find((item) => item.id === editor);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{t("departments")}</h2>
|
||||
<Button onClick={() => setEditor("new")}>{t("newDepartment")}</Button>
|
||||
</div>
|
||||
{company.departments.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("people")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{company.departments.map((department) => (
|
||||
<TableRow key={department.id}>
|
||||
<TableCell>{department.name}</TableCell>
|
||||
<TableCell>{t(department.active ? "active" : "inactive")}</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
company.assignments.filter(
|
||||
(item) => item.departmentId === department.id && item.endDate === null
|
||||
).length
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button onClick={() => setEditor(department.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t("noDepartments")}</p>
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open={editor !== undefined}
|
||||
title={t(selected ? "editDepartment" : "newDepartment")}
|
||||
>
|
||||
<DepartmentForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
department={selected}
|
||||
key={editor}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
/>
|
||||
</FormDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-5">
|
||||
<EntityListHeader
|
||||
actions={<Button onClick={() => setEditor("new")}>{t("newDepartment")}</Button>}
|
||||
locale={i18n.language}
|
||||
title={t("departments")}
|
||||
/>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (item) => item.name,
|
||||
hideable: false,
|
||||
render: (item) => <>{item.name}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(item.active ? "active" : "inactive"),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.active}>
|
||||
{t(item.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
label: t("people"),
|
||||
value: (item) =>
|
||||
company.assignments.filter(
|
||||
(entry) => entry.departmentId === item.id && entry.endDate === null
|
||||
).length,
|
||||
render: (item) => (
|
||||
<>
|
||||
{
|
||||
company.assignments.filter(
|
||||
(entry) => entry.departmentId === item.id && entry.endDate === null
|
||||
).length
|
||||
}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("noDepartments")}
|
||||
items={company.departments}
|
||||
locale={i18n.language}
|
||||
title={t("departments")}
|
||||
/>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open={editor !== undefined}
|
||||
title={t(selected ? "editDepartment" : "newDepartment")}
|
||||
>
|
||||
<DepartmentForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
department={selected}
|
||||
key={editor}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
/>
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -82,57 +80,73 @@ function PersonDetails({
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h3 className="text-lg font-semibold">{t("departmentAssignments")}</h3>
|
||||
<Button disabled={!person.active} onClick={() => setEditor("new")}>
|
||||
{t("addAssignment")}
|
||||
</Button>
|
||||
</div>
|
||||
{assignments.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("department")}</TableHead>
|
||||
<TableHead>{t("position")}</TableHead>
|
||||
<TableHead>{t("startDate")}</TableHead>
|
||||
<TableHead>{t("endDate")}</TableHead>
|
||||
<TableHead>{t("primary")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assignments.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
{departmentName(item.departmentId)}
|
||||
{company.departments.find((department) => department.id === item.departmentId)
|
||||
?.active === false && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("inactiveDepartment")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{item.position ?? "—"}</TableCell>
|
||||
<TableCell>{item.startDate}</TableCell>
|
||||
<TableCell>{item.endDate ?? t("currentAssignment")}</TableCell>
|
||||
<TableCell>{item.isPrimary ? t("yes") : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button onClick={() => setHistoryId(item.id)} size="sm" variant="ghost">
|
||||
{t("history")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t("noAssignments")}</p>
|
||||
)}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<Button disabled={!person.active} onClick={() => setEditor("new")}>
|
||||
{t("addAssignment")}
|
||||
</Button>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("departmentAssignments")}
|
||||
/>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "department",
|
||||
label: t("department"),
|
||||
value: (item) => departmentName(item.departmentId),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<>
|
||||
{departmentName(item.departmentId)}
|
||||
{company.departments.find((entry) => entry.id === item.departmentId)?.active ===
|
||||
false && <p className="text-xs">{t("inactiveDepartment")}</p>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "position",
|
||||
label: t("position"),
|
||||
value: (item) => item.position,
|
||||
render: (item) => <>{item.position ?? "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "start",
|
||||
label: t("startDate"),
|
||||
value: (item) => item.startDate,
|
||||
render: (item) => <>{item.startDate}</>,
|
||||
},
|
||||
{
|
||||
id: "end",
|
||||
label: t("endDate"),
|
||||
value: (item) => item.endDate,
|
||||
render: (item) => <>{item.endDate ?? t("currentAssignment")}</>,
|
||||
},
|
||||
{
|
||||
id: "primary",
|
||||
label: t("primary"),
|
||||
value: (item) => item.isPrimary,
|
||||
render: (item) => <>{item.isPrimary ? t("yes") : "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
<EntityAction onClick={() => setHistoryId(item.id)}>
|
||||
{t("history")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("noAssignments")}
|
||||
items={assignments}
|
||||
locale={i18n.language}
|
||||
title={t("departmentAssignments")}
|
||||
/>
|
||||
{!company.departments.some((item) => item.active) && (
|
||||
<p className="text-sm text-muted-foreground">{t("createDepartmentFirst")}</p>
|
||||
)}
|
||||
@ -194,7 +208,7 @@ function PersonDetails({
|
||||
);
|
||||
}
|
||||
export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const filterId = useId();
|
||||
const [selection, setSelection] = useState<
|
||||
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
||||
@ -247,81 +261,105 @@ export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csr
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{personEditor}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{t("people")}</h2>
|
||||
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={filterId}>{t("searchPerson")}</Label>
|
||||
<Input id={filterId} onChange={(event) => setSearch(event.target.value)} value={search} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={contactsOnly}
|
||||
id={`${filterId}-contact`}
|
||||
onCheckedChange={(checked) => setContactsOnly(checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{people.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("position")}</TableHead>
|
||||
<TableHead>{t("departments")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{people.map((person) => (
|
||||
<TableRow key={person.id}>
|
||||
<TableCell>
|
||||
<span className="font-medium">{person.name}</span>
|
||||
{person.isContact && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("isContact")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{person.position ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
{company.assignments
|
||||
.filter((item) => item.personId === person.id && item.endDate === null)
|
||||
.map(
|
||||
(item) =>
|
||||
(company.departments.find(
|
||||
(department) => department.id === item.departmentId
|
||||
)?.name ?? "—") + (item.isPrimary ? " ★" : "")
|
||||
)
|
||||
.join(", ") || "—"}
|
||||
</TableCell>
|
||||
<TableCell>{t(person.active ? "active" : "inactive")}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
onClick={() => setSelection({ mode: "detail", id: person.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("open")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{t(search || contactsOnly ? "noResults" : "noPeople")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button onClick={() => setSelection({ mode: "new" })}>{t("newPerson")}</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("people")}
|
||||
/>
|
||||
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (person) => person.name,
|
||||
hideable: false,
|
||||
render: (person) => (
|
||||
<>
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setSelection({ mode: "detail", id: person.id })}
|
||||
type="button"
|
||||
>
|
||||
{person.name}
|
||||
</button>
|
||||
{person.isContact && (
|
||||
<p className="text-xs text-muted-foreground">{t("isContact")}</p>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "position",
|
||||
label: t("position"),
|
||||
value: (person) => person.position,
|
||||
render: (person) => <>{person.position ?? "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "departments",
|
||||
label: t("departments"),
|
||||
render: (person) => (
|
||||
<>
|
||||
{company.assignments
|
||||
.filter((item) => item.personId === person.id && item.endDate === null)
|
||||
.map(
|
||||
(item) =>
|
||||
(company.departments.find((department) => department.id === item.departmentId)
|
||||
?.name ?? "—") + (item.isPrimary ? " ★" : "")
|
||||
)
|
||||
.join(", ") || "—"}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (person) => t(person.active ? "active" : "inactive"),
|
||||
render: (person) => (
|
||||
<EntityStatus active={person.active}>
|
||||
{t(person.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (person) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<EntityAction onClick={() => setSelection({ mode: "detail", id: person.id })}>
|
||||
{t("open")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("noPeople")}
|
||||
filterKey={String(contactsOnly)}
|
||||
filters={
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={contactsOnly}
|
||||
id={`${filterId}-contact`}
|
||||
onCheckedChange={(checked) => setContactsOnly(checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</Label>
|
||||
</div>
|
||||
}
|
||||
filtersApplied={contactsOnly}
|
||||
items={people}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() => setContactsOnly(false)}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("searchPerson")}
|
||||
title={t("people")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,76 +1,263 @@
|
||||
import {Button} from "@repo/shadcn-ui/components/button";
|
||||
import {Card,CardContent} from "@repo/shadcn-ui/components/card";
|
||||
import {FormDialog} from "@repo/shadcn-ui/components/form-dialog";
|
||||
import {Input} from "@repo/shadcn-ui/components/input";
|
||||
import {useQuery} from "@tanstack/react-query";
|
||||
import {useState} from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import type {CompanyDTO,AccessItemDTO} from "../../common";
|
||||
import {accessApi,accessKeys} from "../access/access-api";
|
||||
import {profileApi,profileKeys} from "../profiles/work-profile-api";
|
||||
import {folderApi,folderKeys} from "./folder-api";
|
||||
import {FolderFeedback} from "./folder-feedback";
|
||||
import {FolderMappingForm} from "./folder-mapping-form";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function FolderMappingsPage({company,csrfToken,initialProfileId}:{
|
||||
company:CompanyDTO;csrfToken:string;initialProfileId?:string;
|
||||
}){
|
||||
const {t,i18n}=useTranslation("companies");
|
||||
const [editor,setEditor]=useState<string>(),[view,setView]=useState<string>(),[search,setSearch]=useState("");
|
||||
const query=useQuery({queryKey:folderKeys.company(company.id),queryFn:()=>folderApi.get(company.id)});
|
||||
const access=useQuery({queryKey:accessKeys.scope(company.id),queryFn:()=>accessApi.request(company.id)});
|
||||
const profiles=useQuery({queryKey:profileKeys.company(company.id),queryFn:()=>profileApi.get(company.id)});
|
||||
const items=access.data?.items ?? [],mappings=query.data?.mappings ?? [];
|
||||
const profile=(id:string)=>profiles.data?.profiles.find(item=>item.id===id)?.values.name ?? id;
|
||||
const folder=(id:string)=>folderDetails(items,id);
|
||||
const filtered=mappings.filter(item=>(!initialProfileId || item.values.profileId===initialProfileId) &&
|
||||
[item.values.path,profile(item.values.profileId),folder(item.values.needId).name].some(text=>text.toLowerCase().includes(search.trim().toLowerCase())));
|
||||
const edited=mappings.find(item=>item.id===editor),viewed=mappings.find(item=>item.id===view);
|
||||
const ready=[query.data,access.data,profiles.data].every(Boolean);
|
||||
return <div className="space-y-5">
|
||||
<header className="flex flex-wrap justify-between items-center gap-4"><h2 className="text-xl font-semibold">{t("folders.title")}</h2>
|
||||
<Button disabled={!ready} onClick={()=>setEditor("new")}>{t("folders.new")}</Button></header>
|
||||
<Input aria-label={t("folders.search")} placeholder={t("folders.search")} value={search} onChange={event=>setSearch(event.target.value)} />
|
||||
{(query.isPending || access.isPending || profiles.isPending) && <p role="status">{t("loading")}</p>}
|
||||
<FolderFeedback error={query.error ?? access.error ?? profiles.error} />
|
||||
{(query.isError || access.isError || profiles.isError) && <Button variant="outline" onClick={()=>{
|
||||
Promise.all([query.refetch(),access.refetch(),profiles.refetch()]).catch(()=>undefined);
|
||||
}}>{t("retry")}</Button>}
|
||||
{ready && <Card><CardContent className="divide-y pt-3">
|
||||
{!filtered.length && <p className="py-4 text-muted-foreground">{t(mappings.length ? "noResults":"folders.empty")}</p>}
|
||||
{filtered.map(item=><div key={item.id} className="flex flex-wrap justify-between gap-4 py-4">
|
||||
<div className="min-w-0 break-words">
|
||||
<h3 className="font-medium">{folder(item.values.needId).name} · {profile(item.values.profileId)}</h3>
|
||||
<p className="font-mono text-sm">{item.values.driveLetter ? item.values.driveLetter+":":"—"} {item.values.path}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("folders.permissions")}: {folder(item.values.needId).permissions}</p>
|
||||
<p className="text-sm mt-1">{t("mail."+item.values.state)}</p>
|
||||
{!["removed","never_configured"].includes(item.values.state) && <p className="text-sm mt-2">
|
||||
{t(item.permissionsCovered ? "folders.covered":"folders.uncovered")}</p>}
|
||||
{item.needsReview && <p className="text-sm mt-1 text-amber-700">{t("folders.review")}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2 items-start"><Button size="sm" variant="outline" onClick={()=>setView(item.id)}>{t("mail.history")}</Button>
|
||||
{!["removed","never_configured"].includes(item.values.state) && <Button size="sm" variant="outline" onClick={()=>setEditor(item.id)}>{t("edit")}</Button>}</div>
|
||||
</div>)}
|
||||
</CardContent></Card>}
|
||||
{editor && <FormDialog open wide formActions title={t(edited ? "folders.edit":"folders.new")} cancelLabel={t("cancel")}
|
||||
onOpenChange={open=>{if(!open){setEditor(undefined);}}}>
|
||||
<FolderMappingForm company={company} csrfToken={csrfToken} mapping={edited} initialProfileId={initialProfileId ?? ""} onSaved={()=>setEditor(undefined)} />
|
||||
</FormDialog>}
|
||||
{viewed && <FormDialog open wide title={t("mail.history")} cancelLabel={t("close")} onOpenChange={open=>{if(!open){setView(undefined);}}}>
|
||||
<ol className="space-y-3">{[...viewed.history].reverse().map(revision=><li key={revision.id} className="rounded-md border p-3 text-sm">
|
||||
<p className="font-medium">{new Intl.DateTimeFormat(i18n.language,{dateStyle:"medium",timeStyle:"short"}).format(new Date(revision.recordedAt))} · {revision.actorName}</p>
|
||||
<p className="font-mono">{revision.values.driveLetter ? revision.values.driveLetter+":":"—"} {revision.values.path}</p>
|
||||
<p>{t("mail."+revision.values.state)}</p>
|
||||
{revision.values.notes && <p className="whitespace-pre-wrap mt-2">{revision.values.notes}</p>}
|
||||
</li>)}</ol>
|
||||
</FormDialog>}
|
||||
</div>;
|
||||
import type { AccessItemDTO, CompanyDTO } from "../../common";
|
||||
import { accessApi, accessKeys } from "../access/access-api";
|
||||
import { profileApi, profileKeys } from "../profiles/work-profile-api";
|
||||
|
||||
import { folderApi, folderKeys } from "./folder-api";
|
||||
import { FolderFeedback } from "./folder-feedback";
|
||||
import { FolderMappingForm } from "./folder-mapping-form";
|
||||
|
||||
export function FolderMappingsPage({
|
||||
company,
|
||||
csrfToken,
|
||||
initialProfileId,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
initialProfileId?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
const query = useQuery({
|
||||
queryKey: folderKeys.company(company.id),
|
||||
queryFn: () => folderApi.get(company.id),
|
||||
});
|
||||
const access = useQuery({
|
||||
queryKey: accessKeys.scope(company.id),
|
||||
queryFn: () => accessApi.request(company.id),
|
||||
});
|
||||
const profiles = useQuery({
|
||||
queryKey: profileKeys.company(company.id),
|
||||
queryFn: () => profileApi.get(company.id),
|
||||
});
|
||||
const items = access.data?.items ?? [];
|
||||
const mappings = query.data?.mappings ?? [];
|
||||
const profile = (id: string) =>
|
||||
profiles.data?.profiles.find((item) => item.id === id)?.values.name ?? id;
|
||||
const folder = (id: string) => folderDetails(items, id);
|
||||
const filtered = mappings.filter(
|
||||
(item) =>
|
||||
(!initialProfileId || item.values.profileId === initialProfileId) &&
|
||||
[item.values.path, profile(item.values.profileId), folder(item.values.needId).name].some(
|
||||
(text) => text.toLowerCase().includes(search.trim().toLowerCase())
|
||||
)
|
||||
);
|
||||
const edited = mappings.find((item) => item.id === editor);
|
||||
const viewed = mappings.find((item) => item.id === view);
|
||||
const ready = [query.data, access.data, profiles.data].every(Boolean);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||
{t("folders.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("folders.title")}
|
||||
/>
|
||||
|
||||
{(query.isPending || access.isPending || profiles.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
<FolderFeedback error={query.error ?? access.error ?? profiles.error} />
|
||||
{(query.isError || access.isError || profiles.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([query.refetch(), access.refetch(), profiles.refetch()]).catch(
|
||||
() => undefined
|
||||
);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{ready && (
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("folders.title"),
|
||||
value: (item) => folder(item.values.needId).name,
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{folder(item.values.needId).name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
label: t("profiles.title"),
|
||||
value: (item) => profile(item.values.profileId),
|
||||
render: (item) => <>{profile(item.values.profileId)}</>,
|
||||
},
|
||||
{
|
||||
id: "path",
|
||||
label: t("folders.path"),
|
||||
value: (item) => item.values.path,
|
||||
render: (item) => <>{item.values.path}</>,
|
||||
},
|
||||
{
|
||||
id: "drive",
|
||||
label: t("folders.letter"),
|
||||
value: (item) => item.values.driveLetter,
|
||||
render: (item) => (
|
||||
<>{item.values.driveLetter ? `${item.values.driveLetter}:` : "—"}</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
label: t("folders.permissions"),
|
||||
value: (item) => folder(item.values.needId).permissions,
|
||||
render: (item) => <>{folder(item.values.needId).permissions}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(`mail.${item.values.state}`),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.state === "configured"}>
|
||||
{t(`mail.${item.values.state}`)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: t("profiles.review"),
|
||||
value: (item) => item.needsReview,
|
||||
render: (item) => (
|
||||
<>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<p>{t(item.permissionsCovered ? "folders.covered" : "folders.uncovered")}</p>
|
||||
)}
|
||||
{item.needsReview && <p>{t("folders.review")}</p>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setView(item.id)}>
|
||||
{t("mail.history")}
|
||||
</EntityAction>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("folders.empty")}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("folders.search")}
|
||||
title={t("folders.title")}
|
||||
/>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(edited ? "folders.edit" : "folders.new")}
|
||||
wide
|
||||
>
|
||||
<FolderMappingForm
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
initialProfileId={initialProfileId ?? ""}
|
||||
mapping={edited}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
{viewed && (
|
||||
<FormDialog
|
||||
cancelLabel={t("close")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setView(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("mail.history")}
|
||||
wide
|
||||
>
|
||||
<ol className="space-y-3">
|
||||
{[...viewed.history].reverse().map((revision) => (
|
||||
<li className="rounded-md border p-3 text-sm" key={revision.id}>
|
||||
<p className="font-medium">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {revision.actorName}
|
||||
</p>
|
||||
<p className="font-mono">
|
||||
{revision.values.driveLetter ? `${revision.values.driveLetter}:` : "—"}{" "}
|
||||
{revision.values.path}
|
||||
</p>
|
||||
<p>{t(`mail.${revision.values.state}`)}</p>
|
||||
{revision.values.notes && (
|
||||
<p className="whitespace-pre-wrap mt-2">{revision.values.notes}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function folderDetails(items:readonly AccessItemDTO[],id:string){
|
||||
const need=items.find(item=>item.id===id)?.values;
|
||||
if(need?.kind!=="need"){return {name:id,permissions:""};}
|
||||
const resource=items.find(item=>item.id===need.resourceId)?.values;
|
||||
return {name:resource?.kind==="resource" ? resource.name:need.resourceId,
|
||||
permissions:need.permissionIds.map(permission=>{const item=items.find(item=>item.id===permission)?.values;
|
||||
return item?.kind==="permission" ? item.name:permission;}).join(" · ")};
|
||||
export function folderDetails(items: readonly AccessItemDTO[], id: string) {
|
||||
const need = items.find((item) => item.id === id)?.values;
|
||||
if (need?.kind !== "need") {
|
||||
return { name: id, permissions: "" };
|
||||
}
|
||||
const resource = items.find((item) => item.id === need.resourceId)?.values;
|
||||
return {
|
||||
name: resource?.kind === "resource" ? resource.name : need.resourceId,
|
||||
permissions: need.permissionIds
|
||||
.map((permission) => {
|
||||
const item = items.find((item) => item.id === permission)?.values;
|
||||
return item?.kind === "permission" ? item.name : permission;
|
||||
})
|
||||
.join(" · "),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
@ -18,7 +24,7 @@ export function InterventionTemplatesPage({
|
||||
scope?: string;
|
||||
csrfToken: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const query = useQuery({
|
||||
queryKey: interventionTemplateKeys.list(scope),
|
||||
@ -37,40 +43,24 @@ export function InterventionTemplatesPage({
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("interventionTemplates.title")}</h2>
|
||||
<Button disabled={!query.data || query.isError} onClick={() => setEditor({})}>
|
||||
{t("interventionTemplates.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!query.data || query.isError} onClick={() => setEditor({})}>
|
||||
{t("interventionTemplates.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t(
|
||||
scope === "global"
|
||||
? "interventionTemplates.scopeGlobal"
|
||||
: "interventionTemplates.scopeCompany"
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div>
|
||||
<Label className="sr-only" htmlFor={`${prefix}-search`}>
|
||||
{t("interventionTemplates.filter")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}-search`}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("interventionTemplates.filter")}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Checkbox
|
||||
checked={showInactive}
|
||||
id={`${prefix}-inactive`}
|
||||
onCheckedChange={(value) => setShowInactive(value === true)}
|
||||
/>
|
||||
<Label htmlFor={`${prefix}-inactive`}>{t("interventionTemplates.showInactive")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
locale={i18n.language}
|
||||
title={t("interventionTemplates.title")}
|
||||
/>
|
||||
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<InterventionTemplateFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
@ -84,46 +74,106 @@ export function InterventionTemplatesPage({
|
||||
</Button>
|
||||
)}
|
||||
{query.data && !query.isError && (
|
||||
<>
|
||||
{!visible.length && <p>{t("interventionTemplates.empty")}</p>}
|
||||
{visible.map((template) => (
|
||||
<section className="rounded-xl border p-5 space-y-4" key={template.id}>
|
||||
<header className="flex flex-wrap justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-medium">{template.values.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
template.companyId
|
||||
? "interventionTemplates.company"
|
||||
: "interventionTemplates.global"
|
||||
)}{" "}
|
||||
· {t(template.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{template.companyId === (scope === "global" ? null : scope) && (
|
||||
<Button
|
||||
onClick={() => setEditor({ id: template.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (template) => template.values.name,
|
||||
hideable: false,
|
||||
render: (template) => <>{template.values.name}</>,
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
label: t("interventionTemplates.global"),
|
||||
value: (template) =>
|
||||
t(
|
||||
template.companyId
|
||||
? "interventionTemplates.company"
|
||||
: "interventionTemplates.global"
|
||||
),
|
||||
render: (template) => (
|
||||
<>
|
||||
{t(
|
||||
template.companyId
|
||||
? "interventionTemplates.company"
|
||||
: "interventionTemplates.global"
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setEditor({ copyId: template.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventionTemplates.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TemplateSteps values={template.values} />
|
||||
<TemplateHistory template={template} />
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (template) => t(template.values.active ? "active" : "inactive"),
|
||||
render: (template) => (
|
||||
<EntityStatus active={template.values.active}>
|
||||
{t(template.values.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "steps",
|
||||
label: t("interventions.steps"),
|
||||
value: (template) => template.values.steps.length,
|
||||
render: (template) => (
|
||||
<details>
|
||||
<summary className="cursor-pointer">
|
||||
{t("interventions.steps")} ({template.values.steps.length})
|
||||
</summary>
|
||||
<TemplateSteps values={template.values} />
|
||||
</details>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: t("history"),
|
||||
render: (template) => <TemplateHistory template={template} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (template) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
{template.companyId === (scope === "global" ? null : scope) && (
|
||||
<EntityAction onClick={() => setEditor({ id: template.id })}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
)}
|
||||
<EntityAction onClick={() => setEditor({ copyId: template.id })}>
|
||||
{t("interventionTemplates.copy")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("interventionTemplates.empty")}
|
||||
filterKey={String(showInactive)}
|
||||
filters={
|
||||
<div className="flex gap-2 items-center">
|
||||
<Checkbox
|
||||
checked={showInactive}
|
||||
id={`${prefix}-inactive`}
|
||||
onCheckedChange={(value) => setShowInactive(value === true)}
|
||||
/>
|
||||
<Label htmlFor={`${prefix}-inactive`}>
|
||||
{t("interventionTemplates.showInactive")}
|
||||
</Label>
|
||||
</div>
|
||||
}
|
||||
filtersApplied={!showInactive}
|
||||
items={visible}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() => setShowInactive(true)}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("interventionTemplates.filter")}
|
||||
title={t("interventionTemplates.title")}
|
||||
/>
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
|
||||
@ -67,12 +67,14 @@ async function render(node: ReactNode, language = "es") {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("plantillas de intervención: listado sin formulario, es/en, globales no editables desde compañía", async () => {
|
||||
test("plantillas de intervención: listado sin formulario, es/en y catálogo global junto al propio de compañía", async () => {
|
||||
const global = await render(<InterventionTemplatesPage csrfToken="test" />);
|
||||
assert.ok(global.includes("Plantillas de intervención"));
|
||||
assert.equal(global.includes("<form"), false);
|
||||
const scoped = await render(<InterventionTemplatesPage csrfToken="test" scope={companyId} />);
|
||||
assert.equal((scoped.match(/>Editar</g) ?? []).length, 1);
|
||||
assert.equal((scoped.match(/aria-label="Acciones"/g) ?? []).length, 2);
|
||||
assert.ok(scoped.includes(companiesSpanish.interventionTemplates.global));
|
||||
assert.ok(scoped.includes(companiesSpanish.interventionTemplates.company));
|
||||
const english = await render(<InterventionTemplatesPage csrfToken="test" />, "en");
|
||||
assert.ok(english.includes("Intervention templates"));
|
||||
});
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
@ -76,90 +82,25 @@ export function InterventionsPage({
|
||||
: "";
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t(companyId ? "interventions.title" : "interventions.global")}
|
||||
</h2>
|
||||
<Button
|
||||
disabled={!query.data || query.isError}
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("interventions.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Input
|
||||
aria-label={t("interventions.search")}
|
||||
onChange={(event) => setFilters({ ...filters, search: event.target.value })}
|
||||
placeholder={t("interventions.search")}
|
||||
value={filters.search}
|
||||
/>
|
||||
{!companyId && (
|
||||
<SelectWithAction
|
||||
id="interventions-company-filter"
|
||||
label={t("tickets.company")}
|
||||
onValueChange={(company) => setFilters({ ...filters, company })}
|
||||
options={
|
||||
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
|
||||
}
|
||||
placeholder={t("interventions.allCompanies")}
|
||||
value={filters.company}
|
||||
/>
|
||||
)}
|
||||
<SelectWithAction
|
||||
id="interventions-state-filter"
|
||||
label={t("interventions.state")}
|
||||
onValueChange={(state) => setFilters({ ...filters, state })}
|
||||
options={InterventionStateSchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`interventions.states.${value}`),
|
||||
}))}
|
||||
placeholder={t("interventions.allStates")}
|
||||
value={filters.state}
|
||||
/>
|
||||
<SelectWithAction
|
||||
id="interventions-assignee-filter"
|
||||
label={t("interventions.assignee")}
|
||||
onValueChange={(assignee) => setFilters({ ...filters, assignee })}
|
||||
options={[
|
||||
{ value: "unassigned", label: t("interventions.unassigned") },
|
||||
...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ??
|
||||
[]),
|
||||
]}
|
||||
placeholder={t("interventions.allAssignees")}
|
||||
value={filters.assignee}
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("interventions.dateFrom")}
|
||||
onChange={(event) => setFilters({ ...filters, dateFrom: event.target.value })}
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("interventions.dateTo")}
|
||||
onChange={(event) => setFilters({ ...filters, dateTo: event.target.value })}
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
search: "",
|
||||
company: "",
|
||||
state: "",
|
||||
assignee: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventions.resetFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button
|
||||
disabled={!query.data || query.isError}
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("interventions.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t(companyId ? "interventions.title" : "interventions.global")}
|
||||
/>
|
||||
|
||||
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
|
||||
{query.isError && (
|
||||
<div className="space-y-3">
|
||||
@ -174,60 +115,190 @@ export function InterventionsPage({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{query.data && !items.length && (
|
||||
<p className="text-muted-foreground">{t("interventions.empty")}</p>
|
||||
)}
|
||||
<ul className="divide-y">
|
||||
{items.map((intervention) => (
|
||||
<li className="space-y-2 py-4" key={intervention.id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{intervention.reference} · {intervention.companyName}
|
||||
</p>
|
||||
<h3 className="font-semibold">{intervention.values.subject}</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(intervention)} size="sm" variant="outline">
|
||||
{t("interventions.details")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(intervention);
|
||||
setOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{t(`interventions.states.${intervention.values.state}`)} ·{" "}
|
||||
{intervention.assigneeName ?? t("interventions.unassigned")} ·{" "}
|
||||
{intervention.values.minutes === null
|
||||
? t("interventions.unknownTime")
|
||||
: `${intervention.values.minutes} min`}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{intervention.ticketReference ?? t("interventions.internal")}
|
||||
{intervention.values.plannedDate ? ` · ${intervention.values.plannedDate}` : ""}
|
||||
</p>
|
||||
{intervention.values.steps.length > 0 && (
|
||||
<p className="text-sm">
|
||||
{t("interventions.progress", {
|
||||
done: intervention.values.steps.filter((step) => step.state === "done").length,
|
||||
total: intervention.values.steps.length,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "reference",
|
||||
label: t("tickets.reference"),
|
||||
value: (intervention) => intervention.reference,
|
||||
hideable: false,
|
||||
render: (intervention) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(intervention)}
|
||||
type="button"
|
||||
>
|
||||
{intervention.reference}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "subject",
|
||||
label: t("interventions.subject"),
|
||||
value: (intervention) => intervention.values.subject,
|
||||
hideable: false,
|
||||
render: (intervention) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(intervention)}
|
||||
type="button"
|
||||
>
|
||||
{intervention.values.subject}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "company",
|
||||
label: t("tickets.company"),
|
||||
value: (intervention) => intervention.companyName,
|
||||
render: (intervention) => <>{intervention.companyName}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("interventions.state"),
|
||||
value: (intervention) => t(`interventions.states.${intervention.values.state}`),
|
||||
render: (intervention) => (
|
||||
<EntityStatus active={!["finished", "cancelled"].includes(intervention.values.state)}>
|
||||
{t(`interventions.states.${intervention.values.state}`)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "assignee",
|
||||
label: t("interventions.assignee"),
|
||||
value: (intervention) => intervention.assigneeName,
|
||||
render: (intervention) => (
|
||||
<>{intervention.assigneeName ?? t("interventions.unassigned")}</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "minutes",
|
||||
label: t("interventions.minutes"),
|
||||
value: (intervention) => intervention.values.minutes,
|
||||
render: (intervention) => (
|
||||
<>
|
||||
{intervention.values.minutes === null
|
||||
? t("interventions.unknownTime")
|
||||
: `${intervention.values.minutes} min`}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ticket",
|
||||
label: t("interventions.ticket"),
|
||||
value: (intervention) => intervention.ticketReference,
|
||||
render: (intervention) => (
|
||||
<>{intervention.ticketReference ?? t("interventions.internal")}</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "planned",
|
||||
label: t("interventions.plannedDate"),
|
||||
value: (intervention) => intervention.values.plannedDate,
|
||||
render: (intervention) => <>{intervention.values.plannedDate ?? "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "progress",
|
||||
label: t("interventions.steps"),
|
||||
render: (intervention) => (
|
||||
<>
|
||||
{intervention.values.steps.length > 0
|
||||
? t("interventions.progress", {
|
||||
done: intervention.values.steps.filter((step) => step.state === "done")
|
||||
.length,
|
||||
total: intervention.values.steps.length,
|
||||
})
|
||||
: "—"}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
label: t("tickets.updated"),
|
||||
value: (intervention) => intervention.history.at(-1)?.recordedAt,
|
||||
render: (intervention) => <>{date(intervention.history.at(-1)?.recordedAt)}</>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (intervention) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setView(intervention)}>
|
||||
{t("interventions.details")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
onClick={() => {
|
||||
setEdit(intervention);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("interventions.empty")}
|
||||
entityLabel={t("interventions.title")}
|
||||
filterKey={JSON.stringify(filters)}
|
||||
filters={
|
||||
<>
|
||||
{" "}
|
||||
{!companyId && (
|
||||
<SelectWithAction
|
||||
id="interventions-company-filter"
|
||||
label={t("tickets.company")}
|
||||
onValueChange={(company) => setFilters({ ...filters, company })}
|
||||
options={
|
||||
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
|
||||
}
|
||||
placeholder={t("interventions.allCompanies")}
|
||||
value={filters.company}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("tickets.updated")}: {date(intervention.history.at(-1)?.recordedAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<SelectWithAction
|
||||
id="interventions-state-filter"
|
||||
label={t("interventions.state")}
|
||||
onValueChange={(state) => setFilters({ ...filters, state })}
|
||||
options={InterventionStateSchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`interventions.states.${value}`),
|
||||
}))}
|
||||
placeholder={t("interventions.allStates")}
|
||||
value={filters.state}
|
||||
/>
|
||||
<SelectWithAction
|
||||
id="interventions-assignee-filter"
|
||||
label={t("interventions.assignee")}
|
||||
onValueChange={(assignee) => setFilters({ ...filters, assignee })}
|
||||
options={[
|
||||
{ value: "unassigned", label: t("interventions.unassigned") },
|
||||
...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ??
|
||||
[]),
|
||||
]}
|
||||
placeholder={t("interventions.allAssignees")}
|
||||
value={filters.assignee}
|
||||
/>{" "}
|
||||
</>
|
||||
}
|
||||
filtersApplied={Object.values(filters).some(Boolean)}
|
||||
items={items}
|
||||
loading={query.isPending}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() =>
|
||||
setFilters({ search: "", company: "", state: "", assignee: "", dateFrom: "", dateTo: "" })
|
||||
}
|
||||
onSearchChange={(search) => setFilters({ ...filters, search })}
|
||||
search={filters.search}
|
||||
searchPlaceholder={t("interventions.search")}
|
||||
title={t(companyId ? "interventions.title" : "interventions.global")}
|
||||
unavailable={query.isError}
|
||||
/>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -77,21 +82,22 @@ export function MailConfigurationsPage({
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("mail.title")}</h2>
|
||||
<Button
|
||||
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
|
||||
onClick={() => setEditor("new")}
|
||||
>
|
||||
{t("mail.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("mail.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("mail.search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button
|
||||
disabled={!(query.data && inventory.data && profiles.data && mailboxes.data)}
|
||||
onClick={() => setEditor("new")}
|
||||
>
|
||||
{t("mail.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("mail.title")}
|
||||
/>
|
||||
|
||||
{(query.isPending || inventory.isPending || profiles.isPending || mailboxes.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
@ -112,40 +118,84 @@ export function MailConfigurationsPage({
|
||||
</Button>
|
||||
)}
|
||||
{query.data && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(configurations.length ? "noResults" : "mail.empty")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
|
||||
<div className="min-w-0 break-words">
|
||||
<h3 className="font-medium">
|
||||
{address(item)} · {t(`mail.${item.values.method}`)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{target(item.values)}</p>
|
||||
<p className="mt-1 text-sm">{t(`mail.${item.values.state}`)}</p>
|
||||
{item.legacy && <p className="mt-2 text-sm text-amber-700">{t("mail.legacy")}</p>}
|
||||
{item.needsReview && (
|
||||
<p className="mt-2 text-sm text-amber-700">{t("mail.review")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("mail.history")}
|
||||
</Button>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("mail.address"),
|
||||
value: (item) => address(item),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{address(item)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
label: t("profiles.title"),
|
||||
value: (item) => target(item.values),
|
||||
render: (item) => <>{target(item.values)}</>,
|
||||
},
|
||||
{
|
||||
id: "method",
|
||||
label: t("mail.method"),
|
||||
value: (item) => item.values.method,
|
||||
render: (item) => <>{item.values.method}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(`mail.${item.values.state}`),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.state === "configured"}>
|
||||
{t(`mail.${item.values.state}`)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: t("profiles.review"),
|
||||
value: (item) => item.needsReview,
|
||||
render: (item) => (
|
||||
<>
|
||||
{item.legacy && <p>{t("mail.legacy")}</p>}
|
||||
{item.needsReview && <p>{t("mail.review")}</p>}
|
||||
{!(item.legacy || item.needsReview) && "—"}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setView(item.id)}>
|
||||
{t("mail.history")}
|
||||
</EntityAction>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("mail.empty")}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("mail.search")}
|
||||
title={t("mail.title")}
|
||||
/>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -22,7 +27,7 @@ export function PrinterConfigurationsPage({
|
||||
csrfToken: string;
|
||||
initialProfileId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
@ -61,18 +66,19 @@ export function PrinterConfigurationsPage({
|
||||
const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("printers.title")}</h2>
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
{t("printers.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("printers.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("printers.search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!(ready && canCreate)} onClick={() => setEditor("new")}>
|
||||
{t("printers.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("printers.title")}
|
||||
/>
|
||||
|
||||
{(query.isPending || profiles.isPending || inventory.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
@ -90,40 +96,84 @@ export function PrinterConfigurationsPage({
|
||||
</Button>
|
||||
)}
|
||||
{ready && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">{t("printers.empty")}</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="flex flex-wrap justify-between gap-4 py-4" key={item.id}>
|
||||
<div>
|
||||
<h3 className="font-medium">{asset(item.values.printerId)}</h3>
|
||||
<p className="text-sm text-muted-foreground">{label(item.values.profileId)}</p>
|
||||
<p className="text-sm">
|
||||
{t(`printers.${item.values.connection}`)} · {t(`mail.${item.values.state}`)}
|
||||
</p>
|
||||
{item.values.isDefault && (
|
||||
<p className="text-sm font-medium">{t("printers.default")}</p>
|
||||
)}
|
||||
{item.needsReview && (
|
||||
<p className="text-sm text-amber-700">{t("printers.review")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("mail.history")}
|
||||
</Button>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("printers.title"),
|
||||
value: (item) => asset(item.values.printerId),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{asset(item.values.printerId)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
label: t("profiles.title"),
|
||||
value: (item) => label(item.values.profileId),
|
||||
render: (item) => <>{label(item.values.profileId)}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(`mail.${item.values.state}`),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.state === "configured"}>
|
||||
{t(`mail.${item.values.state}`)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "connection",
|
||||
label: t("printers.connection"),
|
||||
value: (item) => t(`printers.${item.values.connection}`),
|
||||
render: (item) => <>{t(`printers.${item.values.connection}`)}</>,
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
label: t("printers.default"),
|
||||
value: (item) => item.values.isDefault,
|
||||
render: (item) => <>{item.values.isDefault ? t("printers.default") : "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: t("profiles.review"),
|
||||
value: (item) => item.needsReview,
|
||||
render: (item) => <>{item.needsReview ? t("printers.review") : "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setView(item.id)}>
|
||||
{t("mail.history")}
|
||||
</EntityAction>
|
||||
{!["removed", "never_configured"].includes(item.values.state) && (
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
)}
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("printers.empty")}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("printers.search")}
|
||||
title={t("printers.title")}
|
||||
/>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
|
||||
@ -63,7 +63,7 @@ test("impresoras: listado sin formularios, alta en cabecera y traducciones", asy
|
||||
assert.ok(html.includes(title ?? ""));
|
||||
assert.ok(html.includes(action ?? ""));
|
||||
assert.ok(!html.includes("<form"));
|
||||
assert.ok(!html.includes("printers."));
|
||||
assert.equal(/(?:>|")printers\./.test(html), false);
|
||||
}
|
||||
});
|
||||
test("impresoras: selectores con alta rápida y ayuda inferior antes de guardar", async () => {
|
||||
@ -78,6 +78,6 @@ test("impresoras: selectores con alta rápida y ayuda inferior antes de guardar"
|
||||
assert.ok(html.includes(text.printers.newAsset));
|
||||
assert.ok(html.indexOf(text.printers.help) > html.indexOf(text.printers.connection));
|
||||
assert.ok(html.indexOf(text.save) > html.indexOf(text.printers.help));
|
||||
assert.ok(!html.includes("printers."));
|
||||
assert.equal(/(?:>|")printers\./.test(html), false);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
@ -9,7 +15,7 @@ import { processApi, processKeys } from "./process-api";
|
||||
import { ProcessFeedback } from "./process-feedback";
|
||||
import { ProcessTemplateForm } from "./process-template-form";
|
||||
export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const query = useQuery({ queryKey: processKeys.catalog(), queryFn: processApi.catalog });
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [copy, setCopy] = useState<string>();
|
||||
@ -22,13 +28,20 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
|
||||
};
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("processes.templates")}</h2>
|
||||
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
|
||||
{t("processes.newTemplate")}
|
||||
</Button>
|
||||
</header>
|
||||
<p className="text-sm text-muted-foreground">{t("processes.globalTemplates")}</p>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!query.data || query.isError} onClick={() => setEditor("new")}>
|
||||
{t("processes.newTemplate")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("processes.globalTemplates")}
|
||||
locale={i18n.language}
|
||||
title={t("processes.templates")}
|
||||
/>
|
||||
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<ProcessFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
@ -42,44 +55,56 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
|
||||
</Button>
|
||||
)}
|
||||
{query.data && !query.isError && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{templates.map((template) => (
|
||||
<section className="space-y-3 py-4" key={template.id}>
|
||||
<header className="flex flex-wrap justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-medium">{template.values.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`processes.${template.values.kind}`)} ·{" "}
|
||||
{t(template.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setEditor(template.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCopy(template.id);
|
||||
setEditor("new");
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("processes.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<ol className="list-decimal pl-5 text-sm">
|
||||
{template.values.instructions.map((instruction) => (
|
||||
<li key={instruction}>{instruction}</li>
|
||||
))}
|
||||
</ol>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (template) => template.values.name,
|
||||
hideable: false,
|
||||
render: (template) => <>{template.values.name}</>,
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
label: t("processes.kind"),
|
||||
value: (template) => t(`processes.${template.values.kind}`),
|
||||
render: (template) => <>{t(`processes.${template.values.kind}`)}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (template) => t(template.values.active ? "active" : "inactive"),
|
||||
render: (template) => (
|
||||
<EntityStatus active={template.values.active}>
|
||||
{t(template.values.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "steps",
|
||||
label: t("processes.instructions"),
|
||||
render: (template) => (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">{t("processes.history")}</summary>
|
||||
<ol className="space-y-2 mt-2">
|
||||
<summary className="cursor-pointer">
|
||||
{t("processes.instructions")} ({template.values.instructions.length})
|
||||
</summary>
|
||||
<ol className="list-decimal pl-5">
|
||||
{template.values.instructions.map((instruction) => (
|
||||
<li key={instruction}>{instruction}</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: t("processes.history"),
|
||||
render: (template) => (
|
||||
<details>
|
||||
<summary className="cursor-pointer">{t("processes.history")}</summary>
|
||||
<ol className="space-y-2">
|
||||
{[...template.history].reverse().map((r) => (
|
||||
<li className="rounded border p-2 text-sm" key={r.id}>
|
||||
<li className="rounded border p-2" key={r.id}>
|
||||
{r.recordedAt} · {r.actorName}
|
||||
<p>
|
||||
{r.values.name} · {t(r.values.active ? "active" : "inactive")}
|
||||
@ -94,10 +119,34 @@ export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) {
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</section>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (template) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setEditor(template.id)}>{t("edit")}</EntityAction>
|
||||
<EntityAction
|
||||
onClick={() => {
|
||||
setCopy(template.id);
|
||||
setEditor("new");
|
||||
}}
|
||||
>
|
||||
{t("processes.copy")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
items={templates}
|
||||
locale={i18n.language}
|
||||
title={t("processes.templates")}
|
||||
/>
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -14,7 +19,7 @@ import { ProcessFeedback } from "./process-feedback";
|
||||
import { ProcessStartForm } from "./process-start-form";
|
||||
import { ProcessTemplatesPage } from "./process-templates-page";
|
||||
export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [templates, setTemplates] = useState(false);
|
||||
const [create, setCreate] = useState(false);
|
||||
const [selected, setSelected] = useState<string>();
|
||||
@ -72,62 +77,112 @@ export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csr
|
||||
) : (
|
||||
!selected && (
|
||||
<>
|
||||
<header className="flex flex-wrap justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold">{t("processes.title")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setTemplates(true)} variant="outline">
|
||||
{t("processes.templates")}
|
||||
</Button>
|
||||
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
|
||||
{t("processes.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("processes.search")}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("processes.search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setTemplates(true)} variant="outline">
|
||||
{t("processes.templates")}
|
||||
</Button>
|
||||
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
|
||||
{t("processes.new")}
|
||||
</Button>
|
||||
</div>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("processes.title")}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={closed}
|
||||
onChange={(e) => setClosed(e.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("processes.showClosed")}
|
||||
</label>
|
||||
|
||||
{query.data && !query.isError && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(processes.length ? "noResults" : "processes.empty")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((p) => (
|
||||
<section className="flex items-start justify-between gap-3 py-4" key={p.id}>
|
||||
<div>
|
||||
<h3 className="font-medium">{p.values.name}</h3>
|
||||
<p>
|
||||
{person(p.values.personId)} · {t(`processes.${p.values.kind}`)} ·{" "}
|
||||
{t(`processes.${p.values.state}`)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("processes.progress", {
|
||||
resolved: p.values.tasks.filter((task) => task.status !== "pending")
|
||||
.length,
|
||||
total: p.values.tasks.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSelected(p.id)} size="sm" variant="outline">
|
||||
{t("processes.openDetail")}
|
||||
</Button>
|
||||
</section>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (p) => p.values.name,
|
||||
hideable: false,
|
||||
render: (p) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setSelected(p.id)}
|
||||
type="button"
|
||||
>
|
||||
{p.values.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "person",
|
||||
label: t("people"),
|
||||
value: (p) => person(p.values.personId),
|
||||
render: (p) => <>{person(p.values.personId)}</>,
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
label: t("processes.kind"),
|
||||
value: (p) => t(`processes.${p.values.kind}`),
|
||||
render: (p) => <>{t(`processes.${p.values.kind}`)}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (p) => t(`processes.${p.values.state}`),
|
||||
render: (p) => (
|
||||
<EntityStatus active={p.values.state === "open"}>
|
||||
{t(`processes.${p.values.state}`)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "progress",
|
||||
label: t("processes.instructions"),
|
||||
render: (p) => (
|
||||
<>
|
||||
{t("processes.progress", {
|
||||
resolved: p.values.tasks.filter((task) => task.status !== "pending")
|
||||
.length,
|
||||
total: p.values.tasks.length,
|
||||
})}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (p) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<EntityAction onClick={() => setSelected(p.id)}>
|
||||
{t("processes.openDetail")}
|
||||
</EntityAction>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("processes.empty")}
|
||||
filterKey={String(closed)}
|
||||
filters={
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={closed}
|
||||
onChange={(e) => setClosed(e.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("processes.showClosed")}
|
||||
</label>
|
||||
}
|
||||
filtersApplied={closed}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() => setClosed(false)}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("processes.search")}
|
||||
title={t("processes.title")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -95,26 +100,19 @@ export function WorkProfilesPage({
|
||||
}
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("profiles.title")}</h2>
|
||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||
{t("profiles.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("profiles.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("profiles.search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||
{t("profiles.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("profiles.title")}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={archived}
|
||||
onChange={(event) => setArchived(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("profiles.showArchived")}
|
||||
</label>
|
||||
|
||||
{(query.isPending || inventory.isPending || access.isPending) && (
|
||||
<p role="status">{t("loading")}</p>
|
||||
)}
|
||||
@ -132,81 +130,125 @@ export function WorkProfilesPage({
|
||||
</Button>
|
||||
)}
|
||||
{ready && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(profiles.length ? "noResults" : "profiles.empty")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
|
||||
<div className="min-w-0 break-words">
|
||||
<h3 className="font-medium">
|
||||
{item.values.name} ·{" "}
|
||||
{t(item.values.active ? "profiles.active" : "profiles.inactive")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{person(item.values.personId)} · {asset(item.values.assetId)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{account(item.values.accountId)}</p>
|
||||
{item.needsReview && (
|
||||
<p className="mt-2 text-sm text-amber-700">{t("profiles.review")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
|
||||
size="sm"
|
||||
>
|
||||
{t("environment.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "certificates", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("certificates.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "applications", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("applications.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "printers", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("printers.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "folders", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("folders.title")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setConfiguration({ kind: "mail", profileId: item.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("mail.title")}
|
||||
</Button>
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("profiles.history")}
|
||||
</Button>
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (item) => item.values.name,
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
|
||||
type="button"
|
||||
>
|
||||
{item.values.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "person",
|
||||
label: t("people"),
|
||||
value: (item) => person(item.values.personId),
|
||||
render: (item) => <>{person(item.values.personId)}</>,
|
||||
},
|
||||
{
|
||||
id: "asset",
|
||||
label: t("profiles.asset"),
|
||||
value: (item) => asset(item.values.assetId),
|
||||
render: (item) => <>{asset(item.values.assetId)}</>,
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
label: t("profiles.account"),
|
||||
value: (item) => account(item.values.accountId),
|
||||
render: (item) => <>{account(item.values.accountId)}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(item.values.active ? "profiles.active" : "profiles.inactive"),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.active}>
|
||||
{t(item.values.active ? "profiles.active" : "profiles.inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: t("profiles.review"),
|
||||
value: (item) => item.needsReview,
|
||||
render: (item) => <>{item.needsReview ? t("profiles.review") : "—"}</>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction
|
||||
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
|
||||
>
|
||||
{t("environment.title")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
onClick={() => setConfiguration({ kind: "certificates", profileId: item.id })}
|
||||
>
|
||||
{t("certificates.title")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
onClick={() => setConfiguration({ kind: "applications", profileId: item.id })}
|
||||
>
|
||||
{t("applications.title")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
onClick={() => setConfiguration({ kind: "printers", profileId: item.id })}
|
||||
>
|
||||
{t("printers.title")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
onClick={() => setConfiguration({ kind: "folders", profileId: item.id })}
|
||||
>
|
||||
{t("folders.title")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
onClick={() => setConfiguration({ kind: "mail", profileId: item.id })}
|
||||
>
|
||||
{t("mail.title")}
|
||||
</EntityAction>
|
||||
<EntityAction onClick={() => setView(item.id)}>
|
||||
{t("profiles.history")}
|
||||
</EntityAction>
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("profiles.empty")}
|
||||
filterKey={String(archived)}
|
||||
filters={
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={archived}
|
||||
onChange={(event) => setArchived(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("profiles.showArchived")}
|
||||
</label>
|
||||
}
|
||||
filtersApplied={archived}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() => setArchived(false)}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("profiles.search")}
|
||||
title={t("profiles.title")}
|
||||
/>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
|
||||
@ -4,9 +4,14 @@ import {
|
||||
inventoryKeys as resourceKeys,
|
||||
} from "@metis/resources/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -65,26 +70,19 @@ export function RemoteConnectionsPage({
|
||||
const ready = Boolean(query.data && assets.data && resources.data && !error);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("remote.title")}</h2>
|
||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||
{t("remote.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("remote.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("remote.search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!ready} onClick={() => setEditor("new")}>
|
||||
{t("remote.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("remote.title")}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={closed}
|
||||
onChange={(event) => setClosed(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("remote.showClosed")}
|
||||
</label>
|
||||
|
||||
{[query, assets, resources].some((q) => q.isPending) && <p role="status">{t("loading")}</p>}
|
||||
<RemoteFeedback error={error} />
|
||||
{error && (
|
||||
@ -100,41 +98,92 @@ export function RemoteConnectionsPage({
|
||||
</Button>
|
||||
)}
|
||||
{ready && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(connections.length ? "noResults" : "remote.empty")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((c) => (
|
||||
<div className="space-y-3 py-4" key={c.id}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="font-medium">{person(c.values.personId)}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(c.id)} size="sm" variant="outline">
|
||||
{t("remote.history")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isClosed(c.values.state)}
|
||||
onClick={() => setEditor(c.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "person",
|
||||
label: t("people"),
|
||||
value: (c) => person(c.values.personId),
|
||||
hideable: false,
|
||||
render: (c) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(c.id)}
|
||||
type="button"
|
||||
>
|
||||
{person(c.values.personId)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "method",
|
||||
label: t("remote.method"),
|
||||
value: (c) => c.values.method,
|
||||
render: (c) => <>{c.values.method}</>,
|
||||
},
|
||||
{
|
||||
id: "details",
|
||||
label: t("remote.title"),
|
||||
render: (c) => (
|
||||
<RemoteSummary
|
||||
assets={inventory}
|
||||
company={company}
|
||||
connection={c}
|
||||
resources={entries}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (c) => t(`mail.${c.values.state}`),
|
||||
render: (c) => (
|
||||
<EntityStatus active={c.values.state === "configured"}>
|
||||
{t(`mail.${c.values.state}`)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (c) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setView(c.id)}>{t("remote.history")}</EntityAction>
|
||||
<EntityAction
|
||||
disabled={isClosed(c.values.state)}
|
||||
onClick={() => setEditor(c.id)}
|
||||
>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("remote.empty")}
|
||||
filterKey={String(closed)}
|
||||
filters={
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={closed}
|
||||
onChange={(event) => setClosed(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("remote.showClosed")}
|
||||
</label>
|
||||
}
|
||||
filtersApplied={closed}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() => setClosed(false)}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("remote.search")}
|
||||
title={t("remote.title")}
|
||||
/>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -41,28 +46,29 @@ export function TeleworkAuthorizationsPage({
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("telework.title")}</h2>
|
||||
<Button
|
||||
disabled={
|
||||
!query.data ||
|
||||
query.isError ||
|
||||
Boolean(
|
||||
initialPersonId &&
|
||||
authorizations.some((item) => item.values.personId === initialPersonId)
|
||||
)
|
||||
}
|
||||
onClick={() => setEditor("new")}
|
||||
>
|
||||
{t("telework.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("telework.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("telework.search")}
|
||||
value={search}
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button
|
||||
disabled={
|
||||
!query.data ||
|
||||
query.isError ||
|
||||
Boolean(
|
||||
initialPersonId &&
|
||||
authorizations.some((item) => item.values.personId === initialPersonId)
|
||||
)
|
||||
}
|
||||
onClick={() => setEditor("new")}
|
||||
>
|
||||
{t("telework.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t("telework.title")}
|
||||
/>
|
||||
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<TeleworkFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
@ -76,31 +82,74 @@ export function TeleworkAuthorizationsPage({
|
||||
</Button>
|
||||
)}
|
||||
{query.data && !query.isError && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(authorizations.length && !initialPersonId ? "noResults" : "telework.empty")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="space-y-3 py-4" key={item.id}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="font-medium">{person(item.values.personId)}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "person",
|
||||
label: t("people"),
|
||||
value: (item) => person(item.values.personId),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{person(item.values.personId)}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(`telework.${item.status}`),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.effective}>{t(`telework.${item.status}`)}</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "start",
|
||||
label: t("telework.startsOn"),
|
||||
value: (item) => item.values.startsOn,
|
||||
render: (item) => <>{item.values.startsOn ?? t("telework.noDate")}</>,
|
||||
},
|
||||
{
|
||||
id: "end",
|
||||
label: t("telework.expiresOn"),
|
||||
value: (item) => item.values.expiresOn,
|
||||
render: (item) => <>{item.values.expiresOn ?? t("telework.noDate")}</>,
|
||||
},
|
||||
{
|
||||
id: "details",
|
||||
label: t("telework.title"),
|
||||
render: (item) => <TeleworkAuthorizationSummary authorization={item} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setView(item.id)}>
|
||||
{t("telework.history")}
|
||||
</Button>
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TeleworkAuthorizationSummary authorization={item} />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</EntityAction>
|
||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("telework.empty")}
|
||||
entityLabel={t("telework.authorizations")}
|
||||
items={filtered}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("telework.search")}
|
||||
title={t("telework.title")}
|
||||
/>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export const teleworkSpanish = {
|
||||
title: "Teletrabajo",
|
||||
authorizations: "Autorizaciones de teletrabajo",
|
||||
new: "Nueva autorización",
|
||||
edit: "Editar autorización de teletrabajo",
|
||||
person: "Persona",
|
||||
@ -48,6 +49,7 @@ export const teleworkSpanish = {
|
||||
};
|
||||
export const teleworkEnglish = {
|
||||
title: "Telework",
|
||||
authorizations: "Remote work authorizations",
|
||||
new: "New authorization",
|
||||
edit: "Edit telework authorization",
|
||||
person: "Person",
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
@ -67,74 +73,25 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
||||
: "";
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t(companyId ? "tickets.title" : "tickets.global")}
|
||||
</h2>
|
||||
<Button
|
||||
disabled={!query.data}
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("tickets.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Input
|
||||
aria-label={t("tickets.search")}
|
||||
onChange={(event) => setFilters({ ...filters, search: event.target.value })}
|
||||
placeholder={t("tickets.search")}
|
||||
value={filters.search}
|
||||
/>
|
||||
{!companyId && (
|
||||
<SelectWithAction
|
||||
id="tickets-company-filter"
|
||||
label={t("tickets.company")}
|
||||
onValueChange={(company) => setFilters({ ...filters, company })}
|
||||
options={
|
||||
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
|
||||
}
|
||||
placeholder={t("tickets.allCompanies")}
|
||||
value={filters.company}
|
||||
/>
|
||||
)}
|
||||
<SelectWithAction
|
||||
id="tickets-state-filter"
|
||||
label={t("tickets.state")}
|
||||
onValueChange={(state) => setFilters({ ...filters, state })}
|
||||
options={TicketStateSchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`tickets.states.${value}`),
|
||||
}))}
|
||||
placeholder={t("tickets.allStates")}
|
||||
value={filters.state}
|
||||
/>
|
||||
<SelectWithAction
|
||||
id="tickets-priority-filter"
|
||||
label={t("tickets.priority")}
|
||||
onValueChange={(priority) => setFilters({ ...filters, priority })}
|
||||
options={TicketPrioritySchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`tickets.priorities.${value}`),
|
||||
}))}
|
||||
placeholder={t("tickets.allPriorities")}
|
||||
value={filters.priority}
|
||||
/>
|
||||
<SelectWithAction
|
||||
id="tickets-assignee-filter"
|
||||
label={t("tickets.assignee")}
|
||||
onValueChange={(assignee) => setFilters({ ...filters, assignee })}
|
||||
options={[
|
||||
{ value: "unassigned", label: t("tickets.unassigned") },
|
||||
...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ??
|
||||
[]),
|
||||
]}
|
||||
placeholder={t("tickets.allAssignees")}
|
||||
value={filters.assignee}
|
||||
/>
|
||||
</div>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button
|
||||
disabled={!query.data}
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("tickets.new")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
locale={i18n.language}
|
||||
title={t(companyId ? "tickets.title" : "tickets.global")}
|
||||
/>
|
||||
|
||||
{query.isPending && <p role="status">{t("tickets.loading")}</p>}
|
||||
{query.isError && (
|
||||
<div className="space-y-3">
|
||||
@ -149,48 +106,173 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{query.data && !items.length && <p className="text-muted-foreground">{t("tickets.empty")}</p>}
|
||||
<ul className="divide-y">
|
||||
{items.map((ticket) => (
|
||||
<li className="space-y-2 py-4" key={ticket.id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ticket.reference} · {ticket.companyName}
|
||||
</p>
|
||||
<h3 className="font-semibold">{ticket.values.subject}</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setInterventionTicket(ticket)} size="sm" variant="outline">
|
||||
{t("interventions.fromTicket")}
|
||||
</Button>
|
||||
<Button onClick={() => setView(ticket)} size="sm" variant="outline">
|
||||
{t("tickets.details")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(ticket);
|
||||
setOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{t(`tickets.states.${ticket.values.state}`)} ·{" "}
|
||||
{t(`tickets.priorities.${ticket.values.priority}`)} ·{" "}
|
||||
{ticket.requesterName ?? t("tickets.unassigned")} ·{" "}
|
||||
{ticket.assigneeName ?? t("tickets.unassigned")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("tickets.updated")}: {date(ticket.history.at(-1)?.recordedAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "reference",
|
||||
label: t("tickets.reference"),
|
||||
value: (ticket) => ticket.reference,
|
||||
hideable: false,
|
||||
render: (ticket) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(ticket)}
|
||||
type="button"
|
||||
>
|
||||
{ticket.reference}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "subject",
|
||||
label: t("tickets.subject"),
|
||||
value: (ticket) => ticket.values.subject,
|
||||
hideable: false,
|
||||
render: (ticket) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setView(ticket)}
|
||||
type="button"
|
||||
>
|
||||
{ticket.values.subject}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "company",
|
||||
label: t("tickets.company"),
|
||||
value: (ticket) => ticket.companyName,
|
||||
render: (ticket) => <>{ticket.companyName}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("tickets.state"),
|
||||
value: (ticket) => t(`tickets.states.${ticket.values.state}`),
|
||||
render: (ticket) => (
|
||||
<EntityStatus active={!["closed", "cancelled"].includes(ticket.values.state)}>
|
||||
{t(`tickets.states.${ticket.values.state}`)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
label: t("tickets.priority"),
|
||||
value: (ticket) => t(`tickets.priorities.${ticket.values.priority}`),
|
||||
render: (ticket) => <>{t(`tickets.priorities.${ticket.values.priority}`)}</>,
|
||||
},
|
||||
{
|
||||
id: "requester",
|
||||
label: t("tickets.requester"),
|
||||
value: (ticket) => ticket.requesterName,
|
||||
render: (ticket) => <>{ticket.requesterName ?? t("tickets.unassigned")}</>,
|
||||
},
|
||||
{
|
||||
id: "assignee",
|
||||
label: t("tickets.assignee"),
|
||||
value: (ticket) => ticket.assigneeName,
|
||||
render: (ticket) => <>{ticket.assigneeName ?? t("tickets.unassigned")}</>,
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
label: t("tickets.updated"),
|
||||
value: (ticket) => ticket.history.at(-1)?.recordedAt,
|
||||
render: (ticket) => <>{date(ticket.history.at(-1)?.recordedAt)}</>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (ticket) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setInterventionTicket(ticket)}>
|
||||
{t("interventions.fromTicket")}
|
||||
</EntityAction>
|
||||
<EntityAction onClick={() => setView(ticket)}>
|
||||
{t("tickets.details")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
onClick={() => {
|
||||
setEdit(ticket);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("tickets.empty")}
|
||||
entityLabel={t("tickets.title")}
|
||||
filterKey={JSON.stringify(filters)}
|
||||
filters={
|
||||
<>
|
||||
{" "}
|
||||
{!companyId && (
|
||||
<SelectWithAction
|
||||
id="tickets-company-filter"
|
||||
label={t("tickets.company")}
|
||||
onValueChange={(company) => setFilters({ ...filters, company })}
|
||||
options={
|
||||
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
|
||||
}
|
||||
placeholder={t("tickets.allCompanies")}
|
||||
value={filters.company}
|
||||
/>
|
||||
)}
|
||||
<SelectWithAction
|
||||
id="tickets-state-filter"
|
||||
label={t("tickets.state")}
|
||||
onValueChange={(state) => setFilters({ ...filters, state })}
|
||||
options={TicketStateSchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`tickets.states.${value}`),
|
||||
}))}
|
||||
placeholder={t("tickets.allStates")}
|
||||
value={filters.state}
|
||||
/>
|
||||
<SelectWithAction
|
||||
id="tickets-priority-filter"
|
||||
label={t("tickets.priority")}
|
||||
onValueChange={(priority) => setFilters({ ...filters, priority })}
|
||||
options={TicketPrioritySchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`tickets.priorities.${value}`),
|
||||
}))}
|
||||
placeholder={t("tickets.allPriorities")}
|
||||
value={filters.priority}
|
||||
/>
|
||||
<SelectWithAction
|
||||
id="tickets-assignee-filter"
|
||||
label={t("tickets.assignee")}
|
||||
onValueChange={(assignee) => setFilters({ ...filters, assignee })}
|
||||
options={[
|
||||
{ value: "unassigned", label: t("tickets.unassigned") },
|
||||
...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ??
|
||||
[]),
|
||||
]}
|
||||
placeholder={t("tickets.allAssignees")}
|
||||
value={filters.assignee}
|
||||
/>{" "}
|
||||
</>
|
||||
}
|
||||
filtersApplied={Object.values(filters).some(Boolean)}
|
||||
items={items}
|
||||
loading={query.isPending}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() =>
|
||||
setFilters({ search: "", company: "", state: "", priority: "", assignee: "" })
|
||||
}
|
||||
onSearchChange={(search) => setFilters({ ...filters, search })}
|
||||
search={filters.search}
|
||||
searchPlaceholder={t("tickets.search")}
|
||||
title={t(companyId ? "tickets.title" : "tickets.global")}
|
||||
unavailable={query.isError}
|
||||
/>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -21,7 +19,7 @@ import { PasswordForm } from "./password-form";
|
||||
import { UserForm } from "./user-form";
|
||||
|
||||
export function UserManagement({ csrfToken }: { csrfToken: string }) {
|
||||
const { t } = useTranslation("identity");
|
||||
const { t, i18n } = useTranslation("identity");
|
||||
const users = useQuery({ queryKey: identityKeys.users, queryFn: identityApi.users });
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [selection, setSelection] = useState<
|
||||
@ -30,13 +28,17 @@ export function UserManagement({ csrfToken }: { csrfToken: string }) {
|
||||
const selected: UserDTO | undefined = users.data?.users.find((user) => user.id === selection?.id);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("users")}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t("usersHelp")}</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>{t("createUser")}</Button>
|
||||
</div>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button onClick={() => setCreateOpen(true)}>{t("createUser")}</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("usersHelp")}
|
||||
locale={i18n.language}
|
||||
title={t("users")}
|
||||
/>
|
||||
{users.isPending && <p role="status">{t("loading")}</p>}
|
||||
<FormFeedback error={users.error} />
|
||||
{users.isError && (
|
||||
@ -50,57 +52,65 @@ export function UserManagement({ csrfToken }: { csrfToken: string }) {
|
||||
</Button>
|
||||
)}
|
||||
{users.data && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("email")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.data.users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
{user.isInitialAdmin && (
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{t("initialAdmin")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{t(user.active ? "active" : "inactive")}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => setSelection({ id: user.id, action: "edit" })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
{!user.isInitialAdmin && (
|
||||
<Button
|
||||
onClick={() => setSelection({ id: user.id, action: "password" })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{t("resetPassword")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{users.data.users.length === 0 && <p>{t("empty")}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (user) => user.name,
|
||||
hideable: false,
|
||||
render: (user) => (
|
||||
<>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
{user.isInitialAdmin && (
|
||||
<p className="text-xs text-muted-foreground">{t("initialAdmin")}</p>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
label: t("email"),
|
||||
value: (user) => user.email,
|
||||
render: (user) => <>{user.email}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (user) => t(user.active ? "active" : "inactive"),
|
||||
render: (user) => (
|
||||
<EntityStatus active={user.active}>
|
||||
{t(user.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (user) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setSelection({ id: user.id, action: "edit" })}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
{!user.isInitialAdmin && (
|
||||
<EntityAction
|
||||
onClick={() => setSelection({ id: user.id, action: "password" })}
|
||||
>
|
||||
{t("resetPassword")}
|
||||
</EntityAction>
|
||||
)}
|
||||
</div>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("empty")}
|
||||
items={users.data.users}
|
||||
locale={i18n.language}
|
||||
title={t("users")}
|
||||
/>
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@repo/shadcn-ui/components/table";
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -28,8 +24,8 @@ type Editor = {
|
||||
copy?: CatalogItemDTO;
|
||||
};
|
||||
export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
||||
const { t } = useTranslation("resources");
|
||||
const searchId = useId();
|
||||
const { t, i18n } = useTranslation("resources");
|
||||
const _searchId = useId();
|
||||
const client = useQueryClient();
|
||||
const [tab, setTab] = useState<"field" | "template">("template");
|
||||
const [search, setSearch] = useState("");
|
||||
@ -55,19 +51,18 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{t(scope === "global" ? "title" : "companyTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||
</p>
|
||||
</div>
|
||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||
{t({ field: "newField", template: "newTemplate" }[tab])}
|
||||
</Button>
|
||||
</div>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||
{t({ field: "newField", template: "newTemplate" }[tab])}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
description={t(scope === "global" ? "introduction" : "companyIntroduction")}
|
||||
locale={i18n.language}
|
||||
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||
/>
|
||||
<nav aria-label={t("title")} className="flex gap-2">
|
||||
{(["template", "field"] as const).map((kind) => (
|
||||
<Button
|
||||
@ -96,49 +91,111 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={searchId}>{t("search")}</Label>
|
||||
<Input
|
||||
id={searchId}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
{query.data &&
|
||||
(visible.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("origin")}</TableHead>
|
||||
<TableHead>{t({ field: "type", template: "fields" }[tab])}</TableHead>
|
||||
<TableHead>{t("active")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map((item) => (
|
||||
<CatalogRow
|
||||
item={item}
|
||||
items={items}
|
||||
key={item.id}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t("empty")}</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (item) => item.values.name,
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="name"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "origin",
|
||||
label: t("origin"),
|
||||
value: (item) => t(item.builtin ? "builtin" : "custom"),
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="origin"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "details",
|
||||
label: t({ field: "type", template: "fields" }[tab]),
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="details"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("active"),
|
||||
value: (item) => item.effectiveActive,
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="state"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<CatalogCell
|
||||
column="actions"
|
||||
item={item}
|
||||
items={items}
|
||||
onCopy={() => setEditor({ kind: "template", copy: item })}
|
||||
onEdit={() => setEditor({ kind: tab, item })}
|
||||
onStatus={(active) => mutation.mutate({ item, active })}
|
||||
onView={() => setView(item)}
|
||||
pending={mutation.isPending}
|
||||
scope={scope}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("empty")}
|
||||
entityLabel={t({ field: "fields", template: "templates", relation: "relations" }[tab])}
|
||||
items={visible}
|
||||
key={tab}
|
||||
loading={query.isPending}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t(scope === "global" ? "title" : "companyTitle")}
|
||||
unavailable={!query.data && query.isError}
|
||||
/>
|
||||
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
|
||||
{editor && (
|
||||
<FormDialog
|
||||
@ -175,7 +232,8 @@ export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfT
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function CatalogRow({
|
||||
function CatalogCell({
|
||||
column,
|
||||
item,
|
||||
scope,
|
||||
items,
|
||||
@ -185,6 +243,7 @@ function CatalogRow({
|
||||
onView,
|
||||
onStatus,
|
||||
}: {
|
||||
column: "name" | "origin" | "details" | "state" | "actions";
|
||||
item: CatalogItemDTO;
|
||||
scope: string;
|
||||
items: readonly CatalogItemDTO[];
|
||||
@ -197,52 +256,54 @@ function CatalogRow({
|
||||
const { t } = useTranslation("resources");
|
||||
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
|
||||
const values = item.values;
|
||||
const inactiveFields =
|
||||
values.kind === "template" &&
|
||||
values.fields.some(
|
||||
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
|
||||
);
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<p className="font-medium">{values.name}</p>
|
||||
{values.description && (
|
||||
<p className="text-xs text-muted-foreground">{values.description}</p>
|
||||
)}
|
||||
{inactiveFields && (
|
||||
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t(item.builtin ? "builtin" : "custom")}
|
||||
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{values.kind === "field"
|
||||
? t(values.type)
|
||||
: t("fieldCount", { count: values.fields.length })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t(item.effectiveActive ? "active" : "inactive")}
|
||||
{scope !== "global" && !item.companyId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(item.overridden ? "overridden" : "inherited")}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CatalogRowActions
|
||||
editable={editable}
|
||||
item={item}
|
||||
onCopy={onCopy}
|
||||
onEdit={onEdit}
|
||||
onStatus={onStatus}
|
||||
onView={onView}
|
||||
pending={pending}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
switch (column) {
|
||||
case "name":
|
||||
return <CatalogName item={item} items={items} onView={onView} />;
|
||||
case "origin":
|
||||
return (
|
||||
<>
|
||||
{t(item.builtin ? "builtin" : "custom")}
|
||||
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
|
||||
</>
|
||||
);
|
||||
case "details":
|
||||
return (
|
||||
<>
|
||||
{values.kind === "field"
|
||||
? t(values.type)
|
||||
: t("fieldCount", { count: values.fields.length })}
|
||||
</>
|
||||
);
|
||||
case "state":
|
||||
return (
|
||||
<>
|
||||
<EntityStatus active={item.effectiveActive}>
|
||||
{t(item.effectiveActive ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
{scope !== "global" && !item.companyId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(item.overridden ? "overridden" : "inherited")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<>
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<CatalogRowActions
|
||||
editable={editable}
|
||||
item={item}
|
||||
onCopy={onCopy}
|
||||
onEdit={onEdit}
|
||||
onStatus={onStatus}
|
||||
onView={onView}
|
||||
pending={pending}
|
||||
/>
|
||||
</EntityRowActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function editorTitle(editor: Editor) {
|
||||
@ -271,33 +332,51 @@ function CatalogRowActions({
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={onView} size="sm" variant="outline">
|
||||
{t("view")}
|
||||
</Button>
|
||||
{editable && (
|
||||
<Button onClick={onEdit} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
{item.values.kind === "template" && (
|
||||
<Button onClick={onCopy} size="sm" variant="outline">
|
||||
{t("copy")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => onStatus(!item.effectiveActive)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="contents">
|
||||
<EntityAction onClick={onView}>{t("view")}</EntityAction>
|
||||
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
|
||||
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
|
||||
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>
|
||||
{t(item.effectiveActive ? "deactivate" : "activate")}
|
||||
</Button>
|
||||
</EntityAction>
|
||||
{item.overridden && (
|
||||
<Button disabled={pending} onClick={() => onStatus(null)} size="sm" variant="ghost">
|
||||
<EntityAction disabled={pending} onClick={() => onStatus(null)}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
</EntityAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogName({
|
||||
item,
|
||||
items,
|
||||
onView,
|
||||
}: {
|
||||
item: CatalogItemDTO;
|
||||
items: readonly CatalogItemDTO[];
|
||||
onView: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const values = item.values;
|
||||
const inactiveFields =
|
||||
values.kind === "template" &&
|
||||
values.fields.some(
|
||||
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={onView}
|
||||
type="button"
|
||||
>
|
||||
{values.name}
|
||||
</button>
|
||||
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}
|
||||
{inactiveFields && (
|
||||
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,15 +40,15 @@ async function render(language: string, children: ReactNode) {
|
||||
}
|
||||
}
|
||||
test("el catálogo muestra plantillas de serie y acción de alta sin formularios junto al listado", async () => {
|
||||
for (const [language, create, copy] of [
|
||||
["es", "Nueva plantilla", "Crear copia"],
|
||||
["en", "New template", "Create copy"],
|
||||
for (const [language, create, actions] of [
|
||||
["es", "Nueva plantilla", "Acciones"],
|
||||
["en", "New template", "Actions"],
|
||||
]) {
|
||||
assert.ok(language && create && copy);
|
||||
assert.ok(language && create && actions);
|
||||
const html = await render(language, <ResourceCatalogPage csrfToken="test" scope="global" />);
|
||||
assert.ok(html.includes("Buzón de correo"));
|
||||
assert.ok(html.includes(create));
|
||||
assert.ok(html.includes(copy));
|
||||
assert.ok(html.includes(actions));
|
||||
assert.equal(html.includes("<form"), false);
|
||||
assert.equal(html.includes(">Editar</button>"), false);
|
||||
}
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { useId, useState } from "react";
|
||||
@ -24,8 +28,8 @@ export function ResourcesPage({
|
||||
csrfToken: string;
|
||||
relatedContent?: (resource: ResourceDTO) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const searchId = useId();
|
||||
const { t, i18n } = useTranslation("resources");
|
||||
const _searchId = useId();
|
||||
const [search, setSearch] = useState("");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [viewId, setViewId] = useState<string>();
|
||||
@ -47,13 +51,20 @@ export function ResourcesPage({
|
||||
) ?? [];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<h2 className="text-2xl font-semibold">{t("inventory")}</h2>
|
||||
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
|
||||
{t("newResource")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t("resourceScope")}</p>
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
|
||||
{t("newResource")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("resourceScope")}
|
||||
locale={i18n.language}
|
||||
title={t("inventory")}
|
||||
/>
|
||||
|
||||
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={query.error ?? catalog.error} />
|
||||
{(query.isError || catalog.isError) && (
|
||||
@ -66,46 +77,68 @@ export function ResourcesPage({
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<Label htmlFor={searchId}>{t("search")}</Label>
|
||||
<Input id={searchId} onChange={(event) => setSearch(event.target.value)} value={search} />
|
||||
{query.data &&
|
||||
(visible.length ? (
|
||||
<div className="divide-y">
|
||||
{visible.map((item) => (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-between gap-4 py-4"
|
||||
key={item.id}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium">{item.values.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.specification.templateName} ·{" "}
|
||||
{t(item.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setViewId(item.id)} size="sm" variant="outline">
|
||||
{t("viewResource")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!catalog.data}
|
||||
onClick={() => setEditor(item.id)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t("empty")}</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (item) => item.values.name,
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={() => setViewId(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{item.values.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "template",
|
||||
label: t("template"),
|
||||
value: (item) => item.specification.templateName,
|
||||
render: (item) => <>{item.specification.templateName}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(item.values.active ? "active" : "inactive"),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.active}>
|
||||
{t(item.values.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setViewId(item.id)}>
|
||||
{t("viewResource")}
|
||||
</EntityAction>
|
||||
<EntityAction disabled={!catalog.data} onClick={() => setEditor(item.id)}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("empty")}
|
||||
items={visible}
|
||||
loading={query.isPending || catalog.isPending}
|
||||
locale={i18n.language}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t("inventory")}
|
||||
unavailable={(!query.data && query.isError) || (!catalog.data && catalog.isError)}
|
||||
/>
|
||||
{editor && catalog.data && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"scripts": {
|
||||
"ui:add": "shadcn add",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
@ -32,6 +32,7 @@
|
||||
"cn": "0.3.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"@base-ui/react": "1.8.0",
|
||||
"lucide-react": "1.8.0"
|
||||
"lucide-react": "1.8.0",
|
||||
"@tanstack/react-table": "8.21.3"
|
||||
}
|
||||
}
|
||||
|
||||
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':
|
||||
specifier: 1.8.0
|
||||
version: 1.8.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user