feat: add template filtering and multi-step resource creation process
- Extend RouteQuerySchema to include optional template UUID for asset filtering. - Update queryMatchesLocation to validate template presence based on route context. - Enhance workspace routes tests to cover template filtering scenarios and invalid cases. - Introduce new localization strings for resource creation steps in English and Spanish. - Refactor resource form controller to support a multi-step creation layout. - Implement resource creation progress component to guide users through the steps. - Create separate UI components for resource fields and identity fields to streamline form structure. - Add validation for required characteristics based on selected templates. - Update resource form actions to handle navigation between steps in the creation process.
This commit is contained in:
parent
002781b56d
commit
2728692b98
@ -153,6 +153,8 @@ EntityTable recibe datos y columnas tipadas; utiliza TanStack Table para búsque
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
El inventario de activos compone las primitivas Tabs de shadcn/ui con EntityTable para filtrar por plantilla. La barra admite desplazamiento horizontal y navegación por teclado; incluye «Todo» y las plantillas del catálogo de la compañía. Un único panel conserva la tabla, su ordenación y sus columnas al cambiar de pestaña. `filterKey` reinicia la página; `filtersApplied` y `onClearFilters` integran el filtro con los estados vacíos y la limpieza común del listado.
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
@ -388,3 +390,35 @@ Los perfiles se deducen por los activos del puesto, incluidos los compartidos; n
|
|||||||
## Gestión de usuarios
|
## Gestión de usuarios
|
||||||
|
|
||||||
`/settings/users` muestra el listado de usuarios como pantalla dentro del espacio global, con acceso directo desde la navegación para el administrador inicial. Identity aporta el contenido administrativo al shell mediante composición; Companies no importa el módulo Identity. Se reutiliza `UserManagement`, su API y los formularios modales existentes para crear, editar y restablecer contraseñas. La activación/desactivación se mantiene en la edición; las personas de las compañías son independientes de estas cuentas. El administrador inicial sigue protegido y la autorización del backend permanece obligatoria.
|
`/settings/users` muestra el listado de usuarios como pantalla dentro del espacio global, con acceso directo desde la navegación para el administrador inicial. Identity aporta el contenido administrativo al shell mediante composición; Companies no importa el módulo Identity. Se reutiliza `UserManagement`, su API y los formularios modales existentes para crear, editar y restablecer contraseñas. La activación/desactivación se mantiene en la edición; las personas de las compañías son independientes de estas cuentas. El administrador inicial sigue protegido y la autorización del backend permanece obligatoria.
|
||||||
|
|
||||||
|
## Alta de recursos por pasos
|
||||||
|
|
||||||
|
El diálogo «Nuevo recurso» reutiliza `FormDialog`, `FormBody`, `FormActions`, `FormSection` y
|
||||||
|
los controles actuales. Su indicador muestra los tres pasos, identifica el actual mediante
|
||||||
|
`aria-current="step"` y anuncia el avance con un texto traducido al que se lleva el foco.
|
||||||
|
Las acciones conservan Cancelar y añaden Anterior/Siguiente; Guardar aparece en el último
|
||||||
|
paso. La ayuda permanece al final del cuerpo, inmediatamente antes del pie.
|
||||||
|
|
||||||
|
`ResourceIdentityFields` y `ResourceFields` comparten identificación, integración y
|
||||||
|
características entre alta y edición. `ResourceFormActions` encapsula las acciones de ambos
|
||||||
|
modos. Estos componentes pertenecen a resources porque conocen sus contratos y textos; no
|
||||||
|
se añade una primitiva genérica de wizard sin otros consumidores. Véase
|
||||||
|
[el comportamiento del alta](recursos-campos-y-plantillas.md#alta-de-recursos-en-tres-pasos).
|
||||||
|
|
||||||
|
|
||||||
|
### Presentación compacta del alta por pasos
|
||||||
|
|
||||||
|
El alta de recursos usa `FormDialog size="medium"` para limitar el ancho a 768 px;
|
||||||
|
la edición completa conserva el tamaño grande. La cabecera aporta un icono decorativo de
|
||||||
|
recurso. El indicador numera los pasos, distingue el actual y marca los completados con
|
||||||
|
un símbolo y texto accesible; las etiquetas permanecen visibles también en móvil.
|
||||||
|
Cada paso incluye una instrucción breve y, tras elegir la plantilla, muestra su nombre.
|
||||||
|
|
||||||
|
Los formularios de recursos reutilizan el patrón compacto de activos:
|
||||||
|
`FormSection variant="plain"`, títulos pequeños en mayúsculas y color
|
||||||
|
`muted-foreground`, `FieldSections layout="form"` para las características y
|
||||||
|
`ContextHelp variant="plain"` al final. Se evitan tarjetas anidadas y títulos grandes
|
||||||
|
entre controles. Las columnas responden al ancho del contenedor; nombre y estado
|
||||||
|
comparten fila cuando hay espacio. Se conservan los márgenes del anillo de foco de
|
||||||
|
`FormBody`, la ayuda de campo, el desplazamiento del cuerpo y las acciones fijas.
|
||||||
|
Se emplean los tokens semánticos del tema, sin estilos ni cambios en vendor.
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
La ficha de compañía incorpora la sección Activos. El listado permite buscar por nombre, plantilla, estado o sede. Nuevo activo abre un modal; Editar y Ficha e historial son acciones explícitas. No se muestran formularios junto al listado.
|
La ficha de compañía incorpora la sección Activos. El listado permite buscar por nombre, plantilla, estado o sede. Nuevo activo abre un modal; Editar y Ficha e historial son acciones explícitas. No se muestran formularios junto al listado.
|
||||||
|
|
||||||
|
El listado ofrece una pestaña «Todo» y una por cada plantilla global o propia disponible en el catálogo de la compañía, incluidas las inactivas y las que todavía no tienen activos. Las pestañas filtran por identidad de plantilla y se combinan con la búsqueda; cambiar de pestaña vuelve a la primera página sin perder la ordenación ni las columnas configuradas. La URL conserva el filtro en `template` y la búsqueda en `q`. Limpiar filtros recupera «Todo» y vacía la búsqueda. Si una plantilla deja de estar disponible, el listado vuelve a «Todo».
|
||||||
|
|
||||||
Un activo representa un elemento concreto, identificado mediante UUID v7. Su nombre sirve para reconocerlo (por ejemplo PORT-ANA-01), pero no sustituye al UUID y puede repetirse. La compañía y la plantilla son obligatorias. Sede, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla. La persona se relaciona mediante un puesto de trabajo o un perfil de usuario.
|
Un activo representa un elemento concreto, identificado mediante UUID v7. Su nombre sirve para reconocerlo (por ejemplo PORT-ANA-01), pero no sustituye al UUID y puede repetirse. La compañía y la plantilla son obligatorias. Sede, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla. La persona se relaciona mediante un puesto de trabajo o un perfil de usuario.
|
||||||
|
|
||||||
Estados técnicos manuales: Operativo, Averiado, En mantenimiento y Retirado. Un activo operativo se muestra Disponible si ningún puesto activo lo utiliza, o En uso si figura como exclusivo o compartido en uno o varios puestos activos. La asignación se calcula desde la composición, independientemente del responsable. Los puestos inactivos y las revisiones históricas no cuentan como uso vigente. Para retirarlo se exige desvincularlo primero de todos los puestos activos, conservando sede e historial. La eliminación definitiva existente sigue protegida por referencias; no hay transferencia entre compañías.
|
Estados técnicos manuales: Operativo, Averiado, En mantenimiento y Retirado. Un activo operativo se muestra Disponible si ningún puesto activo lo utiliza, o En uso si figura como exclusivo o compartido en uno o varios puestos activos. La asignación se calcula desde la composición, independientemente del responsable. Los puestos inactivos y las revisiones históricas no cuentan como uso vigente. Para retirarlo se exige desvincularlo primero de todos los puestos activos, conservando sede e historial. La eliminación definitiva existente sigue protegida por referencias; no hay transferencia entre compañías.
|
||||||
|
|||||||
@ -99,3 +99,22 @@ Las plantillas permiten organizar campos en secciones ordenadas, globales o prop
|
|||||||
El catálogo, las especificaciones congeladas y los valores actuales e históricos de recursos se almacenan en columnas y tablas relacionadas. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md).
|
El catálogo, las especificaciones congeladas y los valores actuales e históricos de recursos se almacenan en columnas y tablas relacionadas. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md).
|
||||||
|
|
||||||
El arranque añade únicamente las definiciones ausentes y no sobrescribe las plantillas existentes. Las ediciones conservan UUID y clave de serie; el uso especializado de la plantilla permanece fijo. Los recursos ya creados conservan su especificación capturada, incluso si posteriormente se retiran campos de la plantilla.
|
El arranque añade únicamente las definiciones ausentes y no sobrescribe las plantillas existentes. Las ediciones conservan UUID y clave de serie; el uso especializado de la plantilla permanece fijo. Los recursos ya creados conservan su especificación capturada, incluso si posteriormente se retiran campos de la plantilla.
|
||||||
|
|
||||||
|
## Alta de recursos en tres pasos
|
||||||
|
|
||||||
|
El diálogo «Nuevo recurso» del listado utiliza un único formulario React Hook Form repartido
|
||||||
|
entre tres pasos: elegir una plantilla activa, completar sus características e integración y
|
||||||
|
revisar el nombre, el estado y las notas opcionales. «Siguiente» valida el paso actual;
|
||||||
|
«Anterior» conserva los datos. El recurso se guarda únicamente desde el último paso.
|
||||||
|
Cambiar a otra plantilla descarta las características y la integración anteriores para no
|
||||||
|
mezclar contratos; elegir de nuevo la misma plantilla conserva sus datos.
|
||||||
|
|
||||||
|
Al entrar en el último paso se propone un nombre de hasta 100 caracteres. Se prioriza la
|
||||||
|
dirección de correo, ruta UNC, producto de software, referencia, titular, URL o proveedor,
|
||||||
|
siempre que el campo pertenezca a la plantilla. Para plantillas propias se utiliza el primer
|
||||||
|
campo de texto informado y, si no hay ninguno, el nombre de la plantilla. Una vez editado
|
||||||
|
por el usuario, el nombre se conserva al retroceder y avanzar. Esta propuesta no rellena las
|
||||||
|
características ni las notas.
|
||||||
|
|
||||||
|
La edición y las altas especializadas integradas en otros formularios mantienen su
|
||||||
|
presentación completa. No cambia el contrato HTTP, la persistencia ni la validación de dominio.
|
||||||
|
|||||||
@ -102,7 +102,8 @@ La consulta conserva los filtros que ya están conectados a la URL:
|
|||||||
- Dashboard y directorio: `companyId`; directorio también `activeOnly` y `q`.
|
- Dashboard y directorio: `companyId`; directorio también `activeOnly` y `q`.
|
||||||
- Tickets: `companyId`, `pendingOnly`, `q`, `state`, `priority` y `assignee`.
|
- Tickets: `companyId`, `pendingOnly`, `q`, `state`, `priority` y `assignee`.
|
||||||
- Intervenciones: `companyId`, `pendingOnly`, `q`, `state`, `assignee`, `dateFrom` y `dateTo`.
|
- Intervenciones: `companyId`, `pendingOnly`, `q`, `state`, `assignee`, `dateFrom` y `dateTo`.
|
||||||
- Activos y recursos: `q`.
|
- Activos: `q` y `template` (UUID de plantilla), tanto en el listado como en la ficha seleccionada.
|
||||||
|
- Recursos: `q`.
|
||||||
- Perfiles y procesos: `q`, con `archived` o `closed`, respectivamente.
|
- Perfiles y procesos: `q`, con `archived` o `closed`, respectivamente.
|
||||||
- Catálogos de activos y recursos: `q` y `tab`; `relations` solo en activos.
|
- Catálogos de activos y recursos: `q` y `tab`; `relations` solo en activos.
|
||||||
- Cuentas y accesos: `q` y `kind`, respetando los tipos disponibles en cada ámbito.
|
- Cuentas y accesos: `q` y `kind`, respetando los tipos disponibles en cada ámbito.
|
||||||
|
|||||||
@ -86,6 +86,8 @@
|
|||||||
"closeAssetPanel": "Close details panel",
|
"closeAssetPanel": "Close details panel",
|
||||||
"showAssetPanel": "Show details panel",
|
"showAssetPanel": "Show details panel",
|
||||||
"inventory": "Assets",
|
"inventory": "Assets",
|
||||||
|
"allAssets": "All",
|
||||||
|
"filterByTemplate": "Filter assets by template",
|
||||||
"chooseValue": "Choose an option",
|
"chooseValue": "Choose an option",
|
||||||
"relatedNotice": "This item is saved separately and remains available if you later cancel the main form.",
|
"relatedNotice": "This item is saved separately and remains available if you later cancel the main form.",
|
||||||
"newAsset": "New asset",
|
"newAsset": "New asset",
|
||||||
|
|||||||
@ -86,6 +86,8 @@
|
|||||||
"closeAssetPanel": "Cerrar ficha lateral",
|
"closeAssetPanel": "Cerrar ficha lateral",
|
||||||
"showAssetPanel": "Mostrar ficha lateral",
|
"showAssetPanel": "Mostrar ficha lateral",
|
||||||
"inventory": "Activos",
|
"inventory": "Activos",
|
||||||
|
"allAssets": "Todo",
|
||||||
|
"filterByTemplate": "Filtrar activos por plantilla",
|
||||||
"chooseValue": "Selecciona una opción",
|
"chooseValue": "Selecciona una opción",
|
||||||
"relatedNotice": "El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.",
|
"relatedNotice": "El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.",
|
||||||
"newAsset": "Nuevo activo",
|
"newAsset": "Nuevo activo",
|
||||||
|
|||||||
@ -29,6 +29,26 @@ export function useAssetInventoryPageController({
|
|||||||
});
|
});
|
||||||
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
const search = searchParams.get("q") ?? "";
|
const search = searchParams.get("q") ?? "";
|
||||||
|
const templates = (catalog.data?.items ?? []).filter((item) => item.values.kind === "template");
|
||||||
|
const requestedTemplateId = searchParams.get("template");
|
||||||
|
const templateId = templates.some((item) => item.id === requestedTemplateId)
|
||||||
|
? requestedTemplateId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const setTemplateId = (value: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value === "all") {
|
||||||
|
params.delete("template");
|
||||||
|
} else {
|
||||||
|
params.set("template", value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFilters = () =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
params.delete("q");
|
||||||
|
params.delete("template");
|
||||||
|
});
|
||||||
|
|
||||||
const setSearch = (value: string) =>
|
const setSearch = (value: string) =>
|
||||||
setSearchParams((params) => {
|
setSearchParams((params) => {
|
||||||
@ -65,6 +85,9 @@ export function useAssetInventoryPageController({
|
|||||||
const viewed = assets.find((asset) => asset.id === viewId);
|
const viewed = assets.find((asset) => asset.id === viewId);
|
||||||
const deleted = assets.find((asset) => asset.id === deleteId);
|
const deleted = assets.find((asset) => asset.id === deleteId);
|
||||||
const filtered = assets.filter((asset) => {
|
const filtered = assets.filter((asset) => {
|
||||||
|
if (templateId && asset.values.templateId !== templateId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const site = lookups.sites.find((site) => site.id === asset.values.siteId);
|
const site = lookups.sites.find((site) => site.id === asset.values.siteId);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -80,6 +103,10 @@ export function useAssetInventoryPageController({
|
|||||||
entityHref,
|
entityHref,
|
||||||
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
||||||
catalog,
|
catalog,
|
||||||
|
templates,
|
||||||
|
templateId,
|
||||||
|
setTemplateId,
|
||||||
|
clearFilters,
|
||||||
search,
|
search,
|
||||||
setSearch,
|
setSearch,
|
||||||
editor,
|
editor,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} from "@repo/rdx-ui/components/entity-list";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CatalogError } from "../../../catalog";
|
import { CatalogError } from "../../../catalog";
|
||||||
@ -28,6 +29,10 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
const {
|
const {
|
||||||
query,
|
query,
|
||||||
catalog,
|
catalog,
|
||||||
|
templates,
|
||||||
|
templateId,
|
||||||
|
setTemplateId,
|
||||||
|
clearFilters,
|
||||||
search,
|
search,
|
||||||
setSearch,
|
setSearch,
|
||||||
editor,
|
editor,
|
||||||
@ -98,105 +103,127 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{query.data && (
|
{query.data && (
|
||||||
<EntityTable
|
<Tabs onValueChange={setTemplateId} value={templateId ?? "all"}>
|
||||||
columns={[
|
<div className="min-w-0 overflow-x-auto">
|
||||||
{
|
<TabsList aria-label={t("filterByTemplate")} variant="line">
|
||||||
id: "name",
|
<TabsTrigger value="all">{t("allAssets")}</TabsTrigger>
|
||||||
label: t("name"),
|
{templates.map((template) => (
|
||||||
value: (asset) => asset.values.name,
|
<TabsTrigger key={template.id} value={template.id}>
|
||||||
hideable: false,
|
{template.values.name}
|
||||||
render: (asset) => (
|
</TabsTrigger>
|
||||||
<BrowserLink
|
))}
|
||||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
</TabsList>
|
||||||
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] app-link text-primary hover:text-primary focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
|
</div>
|
||||||
href={entityHref(asset.id)}
|
<TabsContent keepMounted value={templateId ?? "all"}>
|
||||||
onClick={() => setDetailOpen(true)}
|
<EntityTable
|
||||||
>
|
columns={[
|
||||||
{asset.values.name}
|
{
|
||||||
</BrowserLink>
|
id: "name",
|
||||||
),
|
label: t("name"),
|
||||||
},
|
value: (asset) => asset.values.name,
|
||||||
{
|
hideable: false,
|
||||||
id: "template",
|
render: (asset) => (
|
||||||
label: t("template"),
|
<BrowserLink
|
||||||
value: (asset) => asset.specification.templateName,
|
aria-pressed={selected?.id === asset.id && detailOpen}
|
||||||
render: (asset) => {
|
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] app-link text-primary hover:text-primary focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
|
||||||
const template = catalog.data?.items.find(
|
href={entityHref(asset.id)}
|
||||||
(item) => item.id === asset.values.templateId
|
onClick={() => setDetailOpen(true)}
|
||||||
);
|
>
|
||||||
|
{asset.values.name}
|
||||||
|
</BrowserLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "template",
|
||||||
|
label: t("template"),
|
||||||
|
value: (asset) => asset.specification.templateName,
|
||||||
|
render: (asset) => {
|
||||||
|
const template = catalog.data?.items.find(
|
||||||
|
(item) => item.id === asset.values.templateId
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<AssetTemplateIcon
|
<AssetTemplateIcon
|
||||||
icon={
|
icon={
|
||||||
template?.values.kind === "template" ? template.values.icon : undefined
|
template?.values.kind === "template"
|
||||||
}
|
? template.values.icon
|
||||||
/>
|
: undefined
|
||||||
{asset.specification.templateName}
|
}
|
||||||
</span>
|
/>
|
||||||
);
|
{asset.specification.templateName}
|
||||||
},
|
</span>
|
||||||
},
|
);
|
||||||
{
|
},
|
||||||
id: "state",
|
},
|
||||||
label: t("status"),
|
{
|
||||||
value: (asset) => t(asset.displayStatus),
|
id: "state",
|
||||||
render: (asset) => (
|
label: t("status"),
|
||||||
<EntityStatus active={asset.values.status === "operational"}>
|
value: (asset) => t(asset.displayStatus),
|
||||||
{t(asset.displayStatus)}
|
render: (asset) => (
|
||||||
</EntityStatus>
|
<EntityStatus active={asset.values.status === "operational"}>
|
||||||
),
|
{t(asset.displayStatus)}
|
||||||
},
|
</EntityStatus>
|
||||||
{
|
),
|
||||||
id: "site",
|
},
|
||||||
label: t("site"),
|
{
|
||||||
value: (asset) =>
|
id: "site",
|
||||||
lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
label: t("site"),
|
||||||
t("noAssignment"),
|
value: (asset) =>
|
||||||
render: (asset) => (
|
lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||||
<>
|
t("noAssignment"),
|
||||||
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
render: (asset) => (
|
||||||
t("noAssignment")}
|
<>
|
||||||
</>
|
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||||
),
|
t("noAssignment")}
|
||||||
},
|
</>
|
||||||
{
|
),
|
||||||
id: "actions",
|
},
|
||||||
label: t("actions"),
|
{
|
||||||
hideable: false,
|
id: "actions",
|
||||||
render: (asset) => (
|
label: t("actions"),
|
||||||
<EntityRowActions label={t("actions")}>
|
hideable: false,
|
||||||
{" "}
|
render: (asset) => (
|
||||||
<div className="contents">
|
<EntityRowActions label={t("actions")}>
|
||||||
<EntityAction
|
{" "}
|
||||||
className="inline-flex inventory:hidden"
|
<div className="contents">
|
||||||
onClick={() => setViewId(asset.id)}
|
<EntityAction
|
||||||
>
|
className="inline-flex inventory:hidden"
|
||||||
{t("viewAsset")}
|
onClick={() => setViewId(asset.id)}
|
||||||
</EntityAction>
|
>
|
||||||
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
|
{t("viewAsset")}
|
||||||
{t("edit")}
|
</EntityAction>
|
||||||
</EntityAction>
|
<EntityAction
|
||||||
<EntityAction
|
disabled={!catalog.data}
|
||||||
className="text-destructive data-highlighted:text-destructive"
|
onClick={() => setEditor(asset.id)}
|
||||||
onClick={() => setDeleteId(asset.id)}
|
>
|
||||||
>
|
{t("edit")}
|
||||||
{t("deleteAsset")}
|
</EntityAction>
|
||||||
</EntityAction>
|
<EntityAction
|
||||||
</div>{" "}
|
className="text-destructive data-highlighted:text-destructive"
|
||||||
</EntityRowActions>
|
onClick={() => setDeleteId(asset.id)}
|
||||||
),
|
>
|
||||||
},
|
{t("deleteAsset")}
|
||||||
]}
|
</EntityAction>
|
||||||
emptyMessage={t("noAssets")}
|
</div>{" "}
|
||||||
items={filtered}
|
</EntityRowActions>
|
||||||
locale={i18n.language}
|
),
|
||||||
onSearchChange={setSearch}
|
},
|
||||||
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
|
]}
|
||||||
search={search}
|
emptyMessage={t("noAssets")}
|
||||||
searchPlaceholder={t("search")}
|
filterKey={templateId ?? "all"}
|
||||||
title={t("inventory")}
|
filtersApplied={templateId !== null}
|
||||||
/>
|
items={filtered}
|
||||||
|
locale={i18n.language}
|
||||||
|
onClearFilters={clearFilters}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
|
||||||
|
search={search}
|
||||||
|
searchPlaceholder={t("search")}
|
||||||
|
title={t("inventory")}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AssetSelectionPanel
|
<AssetSelectionPanel
|
||||||
|
|||||||
@ -12,7 +12,12 @@ import { I18nextProvider } from "react-i18next";
|
|||||||
import { buildCatalogResponse } from "../../../api/application/asset-catalog-response.builder";
|
import { buildCatalogResponse } from "../../../api/application/asset-catalog-response.builder";
|
||||||
import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
|
import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
|
||||||
import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
|
import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
|
||||||
import type { RelationshipDTO, WorkstationDTO } from "../../../common";
|
import type {
|
||||||
|
AssetDTO,
|
||||||
|
CatalogResponseDTO,
|
||||||
|
RelationshipDTO,
|
||||||
|
WorkstationDTO,
|
||||||
|
} from "../../../common";
|
||||||
import { AssetSchema } from "../../../common/inventory";
|
import { AssetSchema } from "../../../common/inventory";
|
||||||
import { WorkstationSchema } from "../../../common/workstations";
|
import { WorkstationSchema } from "../../../common/workstations";
|
||||||
import { AssetRelationships } from "../../asset-relationships";
|
import { AssetRelationships } from "../../asset-relationships";
|
||||||
@ -40,7 +45,8 @@ async function render(
|
|||||||
language: string,
|
language: string,
|
||||||
node: ReactNode,
|
node: ReactNode,
|
||||||
relationships: RelationshipDTO[] = [],
|
relationships: RelationshipDTO[] = [],
|
||||||
workstations: WorkstationDTO[] = []
|
workstations: WorkstationDTO[] = [],
|
||||||
|
inventory: { assets: AssetDTO[]; catalog: CatalogResponseDTO } | undefined = undefined
|
||||||
) {
|
) {
|
||||||
const i18n = createInstance();
|
const i18n = createInstance();
|
||||||
|
|
||||||
@ -51,13 +57,14 @@ async function render(
|
|||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
|
|
||||||
client.setQueryData(inventoryKeys.company(companyId), {
|
client.setQueryData(inventoryKeys.company(companyId), {
|
||||||
assets: [],
|
assets: inventory?.assets ?? [],
|
||||||
relationships,
|
relationships,
|
||||||
workstations,
|
workstations,
|
||||||
});
|
});
|
||||||
client.setQueryData(
|
client.setQueryData(
|
||||||
catalogKeys.scope(companyId),
|
catalogKeys.scope(companyId),
|
||||||
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
|
inventory?.catalog ??
|
||||||
|
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
return renderToStaticMarkup(
|
return renderToStaticMarkup(
|
||||||
@ -78,6 +85,8 @@ test("Inventario traducido con alta de cabecera y sin formulario junto al listad
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(html.includes("<form"), false);
|
assert.equal(html.includes("<form"), false);
|
||||||
|
assert.ok(html.includes('role="tablist"'));
|
||||||
|
assert.ok(html.includes(language === "es" ? ">Todo<" : ">All<"));
|
||||||
assert.ok(html.includes(language === "es" ? "Nuevo activo" : "New asset"));
|
assert.ok(html.includes(language === "es" ? "Nuevo activo" : "New asset"));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
html.includes(
|
html.includes(
|
||||||
@ -88,6 +97,103 @@ test("Inventario traducido con alta de cabecera y sin formulario junto al listad
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Las pestañas incluyen plantillas vacías e inactivas y combinan identidad y búsqueda", async () => {
|
||||||
|
const catalog = structuredClone(
|
||||||
|
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
|
||||||
|
);
|
||||||
|
const templates = catalog.items.filter((item) => item.values.kind === "template");
|
||||||
|
const [first, second, empty] = templates;
|
||||||
|
|
||||||
|
assert.ok(first && second && empty);
|
||||||
|
// Dos nombres iguales siguen representando filtros distintos por identidad.
|
||||||
|
second.values.name = first.values.name;
|
||||||
|
empty.active = false;
|
||||||
|
empty.effectiveActive = false;
|
||||||
|
const assets = [first, second].flatMap((template, index) =>
|
||||||
|
["Alpha", "Beta"].map((name) => {
|
||||||
|
const values = {
|
||||||
|
name: `${name}-${index}`,
|
||||||
|
templateId: template.id,
|
||||||
|
siteId: null,
|
||||||
|
status: "operational",
|
||||||
|
notes: "",
|
||||||
|
characteristics: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return AssetSchema.parse({
|
||||||
|
id: UniqueID.generateNewID().toPrimitive(),
|
||||||
|
companyId,
|
||||||
|
specification: {
|
||||||
|
templateId: template.id,
|
||||||
|
templateName: template.values.name,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldId: UniqueID.generateNewID().toPrimitive(),
|
||||||
|
name: "Número de serie",
|
||||||
|
type: "text",
|
||||||
|
unit: "",
|
||||||
|
choices: [],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
values,
|
||||||
|
displayStatus: "available",
|
||||||
|
assigned: false,
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: UniqueID.generateNewID().toPrimitive(),
|
||||||
|
values,
|
||||||
|
actorId: UniqueID.generateNewID().toPrimitive(),
|
||||||
|
actorName: "Técnico",
|
||||||
|
recordedAt: "2026-10-14T10:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [search, expected] of [
|
||||||
|
["", ["Alpha-0", "Beta-0", "Alpha-1", "Beta-1"]],
|
||||||
|
[`?template=${first.id}`, ["Alpha-0", "Beta-0"]],
|
||||||
|
[`?template=${second.id}&q=Alpha`, ["Alpha-1"]],
|
||||||
|
[`?template=${empty.id}`, []],
|
||||||
|
["?template=missing", ["Alpha-0", "Beta-0", "Alpha-1", "Beta-1"]],
|
||||||
|
] satisfies [string, string[]][]) {
|
||||||
|
Object.defineProperty(globalThis, "window", {
|
||||||
|
configurable: true,
|
||||||
|
value: { location: { pathname: `/companies/${companyId}/assets`, search } },
|
||||||
|
});
|
||||||
|
const html = await render(
|
||||||
|
"es",
|
||||||
|
<AssetInventoryPage companyId={companyId} lookups={lookups} />,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
{ assets, catalog }
|
||||||
|
);
|
||||||
|
const table = html.match(/<table[\s\S]*?<\/table>/)?.[0];
|
||||||
|
|
||||||
|
assert.ok(table);
|
||||||
|
for (const asset of assets) {
|
||||||
|
assert.equal(table.includes(asset.values.name), expected.includes(asset.values.name));
|
||||||
|
}
|
||||||
|
assert.equal((html.match(/role="tab"/g) ?? []).length, templates.length + 1);
|
||||||
|
assert.ok(html.includes(empty.values.name));
|
||||||
|
if (expected.length === 0) {
|
||||||
|
assert.ok(html.includes("No hay resultados para estos criterios."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (descriptor) {
|
||||||
|
Object.defineProperty(globalThis, "window", descriptor);
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(globalThis, "window");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
test("El activo ofrece altas relacionadas, ayuda inferior y no encadena una tercera alta", async () => {
|
test("El activo ofrece altas relacionadas, ayuda inferior y no encadena una tercera alta", async () => {
|
||||||
const html = await render(
|
const html = await render(
|
||||||
"es",
|
"es",
|
||||||
|
|||||||
@ -69,6 +69,7 @@ const RouteQuerySchema = z.strictObject({
|
|||||||
closed: z.enum(["true", "false"]).optional(),
|
closed: z.enum(["true", "false"]).optional(),
|
||||||
contactsOnly: z.enum(["true", "false"]).optional(),
|
contactsOnly: z.enum(["true", "false"]).optional(),
|
||||||
q: z.string().max(200).optional(),
|
q: z.string().max(200).optional(),
|
||||||
|
template: z.uuid().optional(),
|
||||||
tab: z.enum(["templates", "fields", "relations"]).optional(),
|
tab: z.enum(["templates", "fields", "relations"]).optional(),
|
||||||
kind: AccessKindSchema.optional(),
|
kind: AccessKindSchema.optional(),
|
||||||
state: z.union([TicketStateSchema, InterventionStateSchema]).optional(),
|
state: z.union([TicketStateSchema, InterventionStateSchema]).optional(),
|
||||||
@ -181,6 +182,9 @@ function queryMatchesLocation(
|
|||||||
if (location.area === "company" && query.companyId && query.companyId !== location.id) {
|
if (location.area === "company" && query.companyId && query.companyId !== location.id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (query.template && !(location.area === "company" && location.section === "inventory")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
stateMatchesLocation(location, query.state) &&
|
stateMatchesLocation(location, query.state) &&
|
||||||
|
|||||||
@ -10,6 +10,46 @@ import {
|
|||||||
|
|
||||||
const COMPANY_ID = "019b8838-44af-776d-a406-af74452b6f26";
|
const COMPANY_ID = "019b8838-44af-776d-a406-af74452b6f26";
|
||||||
const ENTITY_ID = "019b8838-44af-776d-a406-af74452b6f27";
|
const ENTITY_ID = "019b8838-44af-776d-a406-af74452b6f27";
|
||||||
|
const TEMPLATE_ID = "01a1276d-2138-711e-b3d2-99258a02a80f";
|
||||||
|
|
||||||
|
test("el filtro de plantilla permite abrir el listado y las fichas de activos", () => {
|
||||||
|
for (const suffix of ["", `/${ENTITY_ID}`]) {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseWorkspaceUrl(`/companies/${COMPANY_ID}/assets${suffix}?template=${TEMPLATE_ID}&q=Alpha`),
|
||||||
|
{
|
||||||
|
area: "company",
|
||||||
|
id: COMPANY_ID,
|
||||||
|
section: "inventory",
|
||||||
|
...(suffix ? { entityId: ENTITY_ID } : {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
parseWorkspaceUrl(
|
||||||
|
"/companies/01a1016a-289c-761d-9d11-bf10359c0b4e/assets/01a127a0-190b-70fc-bf70-4e41990367b8?template=01a1276d-2138-711e-b3d2-99258a02a80f"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
area: "company",
|
||||||
|
id: "01a1016a-289c-761d-9d11-bf10359c0b4e",
|
||||||
|
section: "inventory",
|
||||||
|
entityId: "01a127a0-190b-70fc-bf70-4e41990367b8",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("el filtro de plantilla rechaza UUID inválidos, parámetros repetidos y otros ámbitos", () => {
|
||||||
|
for (const href of [
|
||||||
|
`/companies/${COMPANY_ID}/assets?template=invalid`,
|
||||||
|
`/companies/${COMPANY_ID}/assets?template=`,
|
||||||
|
`/companies/${COMPANY_ID}/assets?template=${TEMPLATE_ID}&template=${TEMPLATE_ID}`,
|
||||||
|
`/companies/${COMPANY_ID}/resources?template=${TEMPLATE_ID}`,
|
||||||
|
`/companies/${COMPANY_ID}/catalogs/assets?template=${TEMPLATE_ID}`,
|
||||||
|
`/catalogs/assets?template=${TEMPLATE_ID}`,
|
||||||
|
`/tickets?template=${TEMPLATE_ID}`,
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(parseWorkspaceUrl(href), { area: "invalid-route" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("todas las secciones de compañía tienen una ruta propia y el índice es el resumen", () => {
|
test("todas las secciones de compañía tienen una ruta propia y el índice es el resumen", () => {
|
||||||
for (const section of [
|
for (const section of [
|
||||||
@ -124,6 +164,7 @@ test("las rutas desconocidas y los parámetros inválidos no abren una sección
|
|||||||
|
|
||||||
test("la ficha de puesto tiene ruta propia, conserva el UUID y rechaza identificadores inválidos", () => {
|
test("la ficha de puesto tiene ruta propia, conserva el UUID y rechaza identificadores inválidos", () => {
|
||||||
const href = `/companies/${COMPANY_ID}/workstations/${ENTITY_ID}`;
|
const href = `/companies/${COMPANY_ID}/workstations/${ENTITY_ID}`;
|
||||||
|
|
||||||
assert.deepEqual(parseWorkspaceUrl(href), {
|
assert.deepEqual(parseWorkspaceUrl(href), {
|
||||||
area: "company",
|
area: "company",
|
||||||
id: COMPANY_ID,
|
id: COMPANY_ID,
|
||||||
|
|||||||
@ -161,5 +161,19 @@
|
|||||||
"formLayout": {
|
"formLayout": {
|
||||||
"identity": "Resource identification",
|
"identity": "Resource identification",
|
||||||
"template": "Template details"
|
"template": "Template details"
|
||||||
|
},
|
||||||
|
"creationSteps": {
|
||||||
|
"label": "New resource steps",
|
||||||
|
"progress": "Step {{step}} of {{total}}",
|
||||||
|
"template": "Choose template",
|
||||||
|
"fields": "Complete fields",
|
||||||
|
"name": "Name and notes",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Back",
|
||||||
|
"nameHelp": "Name suggested from the resource fields. You can edit it.",
|
||||||
|
"completed": " (completed)",
|
||||||
|
"templateDescription": "Select the template that defines the resource fields and configuration.",
|
||||||
|
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
|
||||||
|
"nameDescription": "Review the suggested name and add notes if you need to provide more context."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -161,5 +161,19 @@
|
|||||||
"formLayout": {
|
"formLayout": {
|
||||||
"identity": "Identificación del recurso",
|
"identity": "Identificación del recurso",
|
||||||
"template": "Datos de la plantilla"
|
"template": "Datos de la plantilla"
|
||||||
|
},
|
||||||
|
"creationSteps": {
|
||||||
|
"label": "Pasos del nuevo recurso",
|
||||||
|
"progress": "Paso {{step}} de {{total}}",
|
||||||
|
"template": "Elegir plantilla",
|
||||||
|
"fields": "Completar campos",
|
||||||
|
"name": "Nombre y notas",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"previous": "Anterior",
|
||||||
|
"nameHelp": "Nombre propuesto a partir de los campos del recurso. Puedes modificarlo.",
|
||||||
|
"completed": " (completado)",
|
||||||
|
"templateDescription": "Selecciona la plantilla que define los campos y la configuración del recurso.",
|
||||||
|
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
|
||||||
|
"nameDescription": "Revisa el nombre propuesto y añade notas si necesitas aportar más contexto."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,78 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function ResourceCreateProgress({
|
||||||
|
step,
|
||||||
|
templateName,
|
||||||
|
}: {
|
||||||
|
step: 1 | 2 | 3;
|
||||||
|
templateName?: string | undefined;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("resources");
|
||||||
|
const currentStep = STEP_KEYS[step - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<ol
|
||||||
|
aria-label={t("creationSteps.label")}
|
||||||
|
className="grid grid-cols-3 gap-3 border-b border-border/60 pb-5"
|
||||||
|
>
|
||||||
|
{STEP_KEYS.map((key, index) => {
|
||||||
|
const current = step === index + 1;
|
||||||
|
const completed = step > index + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
aria-current={current ? "step" : undefined}
|
||||||
|
className="flex min-w-0 flex-col items-start gap-2 sm:flex-row sm:items-center sm:gap-3"
|
||||||
|
key={key}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-semibold ${current ? "border-primary bg-primary text-primary-foreground" : completed ? "border-primary/30 bg-primary/10 text-primary" : "border-border bg-muted/50 text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{completed ? "✓" : index + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs leading-snug sm:text-sm ${current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{t(`creationSteps.${key}`)}
|
||||||
|
{completed && <span className="sr-only">{t("creationSteps.completed")}</span>}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">
|
||||||
|
{t(`creationSteps.${currentStep}`)}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
aria-live="polite"
|
||||||
|
className="text-xs text-muted-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
|
key={step}
|
||||||
|
ref={focusStep}
|
||||||
|
role="status"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{t("creationSteps.progress", { step, total: 3 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{t(`creationSteps.${currentStep}Description`)}
|
||||||
|
</p>
|
||||||
|
{templateName && step > 1 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("template")}: <span className="font-medium text-foreground">{templateName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_KEYS = ["template", "fields", "name"] as const;
|
||||||
|
|
||||||
|
function focusStep(element: HTMLParagraphElement | null) {
|
||||||
|
element?.focus();
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -8,6 +9,7 @@ import type { ResourceInput } from "../../../../common/inventory";
|
|||||||
import { ResourceInputSchema } from "../../../../common/inventory";
|
import { ResourceInputSchema } from "../../../../common/inventory";
|
||||||
import { inventoryApi, inventoryKeys } from "../api";
|
import { inventoryApi, inventoryKeys } from "../api";
|
||||||
import { mailboxIntegration, resourceDefaults, resourceFields } from "../helpers";
|
import { mailboxIntegration, resourceDefaults, resourceFields } from "../helpers";
|
||||||
|
import { hasMissingResourceFields, suggestedResourceName } from "../helpers/resource-form-values";
|
||||||
import type { ResourceFormProps } from "../types";
|
import type { ResourceFormProps } from "../types";
|
||||||
|
|
||||||
export function useResourceFormController({
|
export function useResourceFormController({
|
||||||
@ -18,10 +20,13 @@ export function useResourceFormController({
|
|||||||
initialKind,
|
initialKind,
|
||||||
initialAddress = "",
|
initialAddress = "",
|
||||||
initialIntegration,
|
initialIntegration,
|
||||||
|
creationLayout = "single-page",
|
||||||
}: ResourceFormProps) {
|
}: ResourceFormProps) {
|
||||||
const { t } = useTranslation("resources");
|
const { t } = useTranslation("resources");
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [creatingTemplate, setCreatingTemplate] = useState(false);
|
const [creatingTemplate, setCreatingTemplate] = useState(false);
|
||||||
|
const isWizard = !resource && creationLayout === "steps";
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -100,9 +105,10 @@ export function useResourceFormController({
|
|||||||
characteristics[fieldId] = value;
|
characteristics[fieldId] = value;
|
||||||
}
|
}
|
||||||
form.setValue("characteristics", characteristics, { shouldDirty: true });
|
form.setValue("characteristics", characteristics, { shouldDirty: true });
|
||||||
|
form.clearErrors("characteristics");
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = form.handleSubmit((input) => {
|
const save = form.handleSubmit((input) => {
|
||||||
if (template?.builtinKey === "template:access" && input.integration?.kind !== "access") {
|
if (template?.builtinKey === "template:access" && input.integration?.kind !== "access") {
|
||||||
form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") });
|
form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") });
|
||||||
|
|
||||||
@ -116,7 +122,66 @@ export function useResourceFormController({
|
|||||||
mutation.mutate(integration ? { ...input, integration } : input);
|
mutation.mutate(integration ? { ...input, integration } : input);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const validateFields = async () => {
|
||||||
|
const current = form.getValues();
|
||||||
|
|
||||||
|
if (hasMissingResourceFields(fields, items, current, template)) {
|
||||||
|
form.setError("characteristics", { message: t("inventoryErrors.REQUIRED_CHARACTERISTICS") });
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (template?.builtinKey === "template:access" && current.integration?.kind !== "access") {
|
||||||
|
form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") });
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return form.trigger(["characteristics", "integration"]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
if (mutation.isPending) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isWizard || step === 3) {
|
||||||
|
await save(event);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (step === 1) {
|
||||||
|
if (template && (await form.trigger("templateId"))) {
|
||||||
|
setStep(2);
|
||||||
|
} else {
|
||||||
|
form.setError("templateId", { message: t("inventoryErrors.TEMPLATE_UNAVAILABLE") });
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await validateFields())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.formState.dirtyFields.name) {
|
||||||
|
form.setValue(
|
||||||
|
"name",
|
||||||
|
suggestedResourceName(template, fields, items, form.getValues("characteristics"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setStep(3);
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousStep = () => {
|
||||||
|
form.clearErrors();
|
||||||
|
setStep((current) => (current === 3 ? 2 : 1));
|
||||||
|
};
|
||||||
|
|
||||||
const selectTemplate = (templateId: string) => {
|
const selectTemplate = (templateId: string) => {
|
||||||
|
if (templateId === form.getValues("templateId")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.clearErrors();
|
||||||
form.setValue("templateId", templateId);
|
form.setValue("templateId", templateId);
|
||||||
form.setValue("characteristics", {});
|
form.setValue("characteristics", {});
|
||||||
form.setValue("integration", undefined);
|
form.setValue("integration", undefined);
|
||||||
@ -129,12 +194,14 @@ export function useResourceFormController({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const templateCreated = (id: string) => {
|
const templateCreated = (id: string) => {
|
||||||
form.setValue("templateId", id);
|
selectTemplate(id);
|
||||||
form.setValue("characteristics", {});
|
|
||||||
setCreatingTemplate(false);
|
setCreatingTemplate(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isWizard,
|
||||||
|
step,
|
||||||
|
previousStep,
|
||||||
creatingTemplate,
|
creatingTemplate,
|
||||||
setCreatingTemplate,
|
setCreatingTemplate,
|
||||||
referenceQuery,
|
referenceQuery,
|
||||||
|
|||||||
@ -71,3 +71,56 @@ export function resourceDefaults(
|
|||||||
characteristics: addressField && initialAddress ? { [addressField.id]: initialAddress } : {},
|
characteristics: addressField && initialAddress ? { [addressField.id]: initialAddress } : {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasMissingResourceFields(
|
||||||
|
fields: ReturnType<typeof resourceFields>,
|
||||||
|
items: readonly CatalogItemDTO[],
|
||||||
|
values: ResourceInput,
|
||||||
|
template: CatalogItemDTO | undefined
|
||||||
|
) {
|
||||||
|
return fields.some((field) => {
|
||||||
|
const value = values.characteristics[field.fieldId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
requireMailboxAddress(field, items, values, template).required &&
|
||||||
|
(value === undefined || (typeof value === "string" && !value.trim()))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prioriza identificadores documentales; las plantillas propias usan su primer campo de texto. */
|
||||||
|
export function suggestedResourceName(
|
||||||
|
template: CatalogItemDTO | undefined,
|
||||||
|
fields: ReturnType<typeof resourceFields>,
|
||||||
|
items: readonly CatalogItemDTO[],
|
||||||
|
characteristics: ResourceInput["characteristics"]
|
||||||
|
) {
|
||||||
|
const preferredKeys = [
|
||||||
|
"field:email",
|
||||||
|
"field:unc",
|
||||||
|
"field:software_product",
|
||||||
|
"field:reference",
|
||||||
|
"field:holder",
|
||||||
|
"field:url",
|
||||||
|
"field:provider",
|
||||||
|
];
|
||||||
|
const preferredIds = preferredKeys.flatMap((key) => {
|
||||||
|
const item = items.find((candidate) => candidate.builtinKey === key);
|
||||||
|
|
||||||
|
return item ? [item.id] : [];
|
||||||
|
});
|
||||||
|
const candidates = [
|
||||||
|
...preferredIds,
|
||||||
|
...fields.filter((field) => field.type === "text").map((field) => field.fieldId),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const id of candidates) {
|
||||||
|
const value = characteristics[id];
|
||||||
|
|
||||||
|
if (fields.some((field) => field.fieldId === id) && typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim().slice(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return template?.values.name.slice(0, 100) ?? "";
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { Maybe } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { buildCatalogResponse } from "../../../api/application/resource-catalog-response.builder";
|
||||||
|
import { ResourceCatalog } from "../../../api/domain/resource-catalog.aggregate";
|
||||||
|
import { seedResourceCatalog } from "../../../api/domain/resource-catalog-seed";
|
||||||
|
import type { ResourceCharacteristic } from "../../../common/inventory";
|
||||||
|
|
||||||
import { mailboxIntegration, resourceDefaults, resourceFields } from "./helpers";
|
import { mailboxIntegration, resourceDefaults, resourceFields } from "./helpers";
|
||||||
|
import { hasMissingResourceFields, suggestedResourceName } from "./helpers/resource-form-values";
|
||||||
|
|
||||||
const FIELD_ID = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4";
|
const FIELD_ID = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4";
|
||||||
|
|
||||||
@ -29,3 +37,113 @@ test("los valores iniciales toleran catálogo pendiente y limitan el nombre sin
|
|||||||
assert.deepEqual(resourceFields(undefined, undefined, []), []);
|
assert.deepEqual(resourceFields(undefined, undefined, []), []);
|
||||||
assert.equal(name.length, 120);
|
assert.equal(name.length, 120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("nombre propuesto: prioriza los identificadores de cada plantilla y limita su longitud", () => {
|
||||||
|
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
|
||||||
|
|
||||||
|
for (const [templateKey, fieldKey, input] of [
|
||||||
|
["mailbox", "email", " soporte@empresa.es "],
|
||||||
|
["shared_folder", "unc", "\\\\servidor\\contabilidad"],
|
||||||
|
["software_license", "software_product", "Microsoft 365 Business Premium"],
|
||||||
|
["certificate", "holder", "Empresa S. L."],
|
||||||
|
["application", "url", "https://portal.empresa.es"],
|
||||||
|
]) {
|
||||||
|
const template = catalog.items.find((item) => item.builtinKey === `template:${templateKey}`);
|
||||||
|
const field = catalog.items.find((item) => item.builtinKey === `field:${fieldKey}`);
|
||||||
|
|
||||||
|
assert.ok(template && field && input);
|
||||||
|
const fields = resourceFields(undefined, template, catalog.items);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
suggestedResourceName(template, fields, catalog.items, { [field.id]: input }),
|
||||||
|
input.trim()
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
suggestedResourceName(template, fields, catalog.items, { [field.id]: "x".repeat(120) })
|
||||||
|
.length,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
suggestedResourceName(template, fields, catalog.items, { [field.id]: " " }),
|
||||||
|
template.values.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nombre propuesto: contempla campos personalizados y descarta valores no identificativos", () => {
|
||||||
|
const field: ResourceCharacteristic = {
|
||||||
|
fieldId: FIELD_ID,
|
||||||
|
name: "Identificador",
|
||||||
|
type: "text",
|
||||||
|
unit: "",
|
||||||
|
choices: [],
|
||||||
|
required: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
suggestedResourceName(undefined, [field], [], { [FIELD_ID]: " Recurso propio " }),
|
||||||
|
"Recurso propio"
|
||||||
|
);
|
||||||
|
for (const value of [false, 0, " "]) {
|
||||||
|
assert.equal(suggestedResourceName(undefined, [field], [], { [FIELD_ID]: value }), "");
|
||||||
|
}
|
||||||
|
assert.equal(suggestedResourceName(undefined, [], [], { [FIELD_ID]: "Campo ajeno" }), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("paso de campos: exige valores obligatorios y acepta cero y respuestas negativas", () => {
|
||||||
|
const field: ResourceCharacteristic = {
|
||||||
|
fieldId: FIELD_ID,
|
||||||
|
name: "Dato obligatorio",
|
||||||
|
type: "text",
|
||||||
|
unit: "",
|
||||||
|
choices: [],
|
||||||
|
required: true,
|
||||||
|
};
|
||||||
|
const values = resourceDefaults([], undefined, undefined, "", undefined);
|
||||||
|
|
||||||
|
for (const characteristics of [{}, { [FIELD_ID]: "" }, { [FIELD_ID]: " " }]) {
|
||||||
|
assert.equal(
|
||||||
|
hasMissingResourceFields([field], [], { ...values, characteristics }, undefined),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const value of [0, false, "Dato informado"]) {
|
||||||
|
assert.equal(
|
||||||
|
hasMissingResourceFields(
|
||||||
|
[field],
|
||||||
|
[],
|
||||||
|
{ ...values, characteristics: { [FIELD_ID]: value } },
|
||||||
|
undefined
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
hasMissingResourceFields([{ ...field, required: false }], [], values, undefined),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("paso de campos: requiere la dirección de buzón aunque la plantilla la marque opcional", () => {
|
||||||
|
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
|
||||||
|
const template = catalog.items.find((item) => item.builtinKey === "template:mailbox");
|
||||||
|
const address = catalog.items.find((item) => item.builtinKey === "field:email");
|
||||||
|
|
||||||
|
assert.ok(template && address);
|
||||||
|
const fields = resourceFields(undefined, template, catalog.items).map((field) => ({
|
||||||
|
...field,
|
||||||
|
required: false,
|
||||||
|
}));
|
||||||
|
const values = resourceDefaults(catalog.items, undefined, "mailbox", "", undefined);
|
||||||
|
|
||||||
|
assert.equal(hasMissingResourceFields(fields, catalog.items, values, template), true);
|
||||||
|
assert.equal(
|
||||||
|
hasMissingResourceFields(
|
||||||
|
fields,
|
||||||
|
catalog.items,
|
||||||
|
{ ...values, characteristics: { [address.id]: "soporte@empresa.es" } },
|
||||||
|
template
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { ResourceCatalog } from "../../../api/domain/resource-catalog.aggregate"
|
|||||||
import { seedResourceCatalog } from "../../../api/domain/resource-catalog-seed";
|
import { seedResourceCatalog } from "../../../api/domain/resource-catalog-seed";
|
||||||
import { catalogKeys } from "../../catalog/shared/api";
|
import { catalogKeys } from "../../catalog/shared/api";
|
||||||
import { resourcesEnglish, resourcesSpanish } from "../../shared";
|
import { resourcesEnglish, resourcesSpanish } from "../../shared";
|
||||||
|
import { ResourceCreateProgress } from "../create/ui/resource-create-progress";
|
||||||
import { ResourcesPage } from "../list";
|
import { ResourcesPage } from "../list";
|
||||||
|
|
||||||
import { inventoryKeys } from "./api";
|
import { inventoryKeys } from "./api";
|
||||||
@ -124,6 +125,7 @@ test("licencia: formulario reutilizable con campos opcionales y ayuda antes de g
|
|||||||
language,
|
language,
|
||||||
<ResourceForm
|
<ResourceForm
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
|
creationLayout="steps"
|
||||||
items={catalog.items}
|
items={catalog.items}
|
||||||
onSaved={() => undefined}
|
onSaved={() => undefined}
|
||||||
resource={resource}
|
resource={resource}
|
||||||
@ -163,3 +165,58 @@ test("asistencia: alta de recurso con plantilla específica y características o
|
|||||||
assert.ok(html.includes("URL"));
|
assert.ok(html.includes("URL"));
|
||||||
assert.equal(html.includes('aria-label="Nueva plantilla"'), false);
|
assert.equal(html.includes('aria-label="Nueva plantilla"'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("alta por pasos: solo muestra la plantilla inicialmente y traduce su navegación", async () => {
|
||||||
|
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
|
||||||
|
|
||||||
|
for (const [language, progress, next] of [
|
||||||
|
["es", "Paso 1 de 3", "Siguiente"],
|
||||||
|
["en", "Step 1 of 3", "Next"],
|
||||||
|
]) {
|
||||||
|
assert.ok(language && progress && next);
|
||||||
|
const html = await render(
|
||||||
|
language,
|
||||||
|
<ResourceForm
|
||||||
|
companyId={companyId}
|
||||||
|
creationLayout="steps"
|
||||||
|
items={catalog.items}
|
||||||
|
onSaved={() => undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(html.includes(progress));
|
||||||
|
assert.ok(html.includes('aria-current="step"'));
|
||||||
|
assert.ok(html.includes(next));
|
||||||
|
assert.equal(html.includes('name="name"'), false);
|
||||||
|
assert.equal(html.includes('name="notes"'), false);
|
||||||
|
assert.equal(html.includes("Cantidad contratada"), false);
|
||||||
|
assert.match(html, /<button(?=[^>]*type="submit")(?=[^>]*disabled="")[^>]*>/);
|
||||||
|
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("indicador de alta: conserva la plantilla elegida y explica el paso actual en ambos idiomas", async () => {
|
||||||
|
for (const [language, completed, fieldsDescription, nameDescription] of [
|
||||||
|
[
|
||||||
|
"es",
|
||||||
|
"(completado)",
|
||||||
|
"Los campos marcados con * son obligatorios.",
|
||||||
|
"Revisa el nombre propuesto",
|
||||||
|
],
|
||||||
|
["en", "(completed)", "Fields marked with * are required.", "Review the suggested name"],
|
||||||
|
]) {
|
||||||
|
assert.ok(language && completed && fieldsDescription && nameDescription);
|
||||||
|
for (const step of [2, 3] as const) {
|
||||||
|
const html = await render(
|
||||||
|
language,
|
||||||
|
<ResourceCreateProgress step={step} templateName="Buzón de correo" />
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(html.includes("Buzón de correo"));
|
||||||
|
assert.ok(html.includes(completed));
|
||||||
|
assert.ok(html.includes(step === 2 ? fieldsDescription : nameDescription));
|
||||||
|
assert.equal((html.match(/aria-current="step"/g) ?? []).length, 1);
|
||||||
|
assert.equal(html.includes("creationSteps."), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export type ResourceFormProps = {
|
|||||||
items: readonly CatalogItemDTO[];
|
items: readonly CatalogItemDTO[];
|
||||||
onSaved: (id: string) => void;
|
onSaved: (id: string) => void;
|
||||||
allowQuickCreate?: boolean;
|
allowQuickCreate?: boolean;
|
||||||
|
creationLayout?: "single-page" | "steps";
|
||||||
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | "os" | undefined;
|
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | "os" | undefined;
|
||||||
initialAddress?: string;
|
initialAddress?: string;
|
||||||
initialIntegration?: ResourceInput["integration"];
|
initialIntegration?: ResourceInput["integration"];
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||||
|
import { LayersIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -29,10 +30,21 @@ export function ResourceEditorDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open
|
open
|
||||||
title={t(resource ? "editResource" : "newResource")}
|
size={resource ? "large" : "medium"}
|
||||||
wide
|
title={
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<LayersIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
{t(resource ? "editResource" : "newResource")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ResourceForm companyId={companyId} items={items} onSaved={onSaved} resource={resource} />
|
<ResourceForm
|
||||||
|
companyId={companyId}
|
||||||
|
creationLayout="steps"
|
||||||
|
items={items}
|
||||||
|
onSaved={onSaved}
|
||||||
|
resource={resource}
|
||||||
|
/>
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,85 @@
|
|||||||
|
import { FieldSections } from "@repo/rdx-ui/components/field-sections";
|
||||||
|
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||||
|
import { fieldHelpById } from "@repo/rdx-ui/lib/field-help";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CatalogItemDTO,
|
||||||
|
ResourceCharacteristic,
|
||||||
|
ResourceDTO,
|
||||||
|
ResourceInput,
|
||||||
|
ResourceReferenceDTO,
|
||||||
|
} from "../../../../common";
|
||||||
|
import { requireMailboxAddress } from "../helpers";
|
||||||
|
|
||||||
|
import { ResourceCharacteristicControl } from "./resource-characteristic-control";
|
||||||
|
import { ResourceIntegrationFields } from "./resource-integration-fields";
|
||||||
|
|
||||||
|
export function ResourceFields({
|
||||||
|
fields,
|
||||||
|
sections,
|
||||||
|
items,
|
||||||
|
values,
|
||||||
|
template,
|
||||||
|
references,
|
||||||
|
prefix,
|
||||||
|
disabled,
|
||||||
|
fixed,
|
||||||
|
hasErrors,
|
||||||
|
onCharacteristicChange,
|
||||||
|
onIntegrationChange,
|
||||||
|
}: {
|
||||||
|
fields: readonly ResourceCharacteristic[];
|
||||||
|
sections: ResourceDTO["specification"]["sections"];
|
||||||
|
items: readonly CatalogItemDTO[];
|
||||||
|
values: ResourceInput;
|
||||||
|
template: CatalogItemDTO | undefined;
|
||||||
|
references: readonly ResourceReferenceDTO[];
|
||||||
|
prefix: string;
|
||||||
|
disabled: boolean;
|
||||||
|
fixed: boolean;
|
||||||
|
hasErrors: boolean;
|
||||||
|
onCharacteristicChange: (
|
||||||
|
fieldId: string,
|
||||||
|
value: ResourceInput["characteristics"][string] | undefined
|
||||||
|
) => void;
|
||||||
|
onIntegrationChange: (value: ResourceInput["integration"]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("resources");
|
||||||
|
const fieldHelp = fieldHelpById(items);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ResourceIntegrationFields
|
||||||
|
fixed={fixed}
|
||||||
|
integration={values.integration}
|
||||||
|
items={items}
|
||||||
|
onChange={onIntegrationChange}
|
||||||
|
references={references}
|
||||||
|
templateKey={template?.builtinKey}
|
||||||
|
/>
|
||||||
|
{fields.length > 0 && (
|
||||||
|
<FormSection title={t("characteristics")} variant="plain">
|
||||||
|
<FieldSections
|
||||||
|
hasErrors={hasErrors}
|
||||||
|
items={fields}
|
||||||
|
layout="form"
|
||||||
|
renderItem={(field) => (
|
||||||
|
<ResourceCharacteristicControl
|
||||||
|
disabled={disabled}
|
||||||
|
field={requireMailboxAddress(field, items, values, template)}
|
||||||
|
helpText={fieldHelp.get(field.fieldId)}
|
||||||
|
key={field.fieldId}
|
||||||
|
onChange={(value) => onCharacteristicChange(field.fieldId, value)}
|
||||||
|
prefix={prefix}
|
||||||
|
value={values.characteristics[field.fieldId]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
sections={sections}
|
||||||
|
ungroupedLabel={t("sections.ungrouped")}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function ResourceFormActions({
|
||||||
|
step,
|
||||||
|
pending,
|
||||||
|
canContinue,
|
||||||
|
onPrevious,
|
||||||
|
}: {
|
||||||
|
step: 1 | 2 | 3 | undefined;
|
||||||
|
pending: boolean;
|
||||||
|
canContinue: boolean;
|
||||||
|
onPrevious: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("resources");
|
||||||
|
const label = step && step < 3 ? "creationSteps.next" : pending ? "saving" : "save";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormActions>
|
||||||
|
{step && step > 1 && (
|
||||||
|
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
|
||||||
|
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
||||||
|
{t("creationSteps.previous")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button disabled={pending || (step === 1 && !canContinue)} type="submit">
|
||||||
|
{t(label)}
|
||||||
|
{step && step < 3 && <ChevronRightIcon aria-hidden="true" className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,5 @@
|
|||||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||||
import { FieldSections } from "@repo/rdx-ui/components/field-sections";
|
import { FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
|
||||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
|
||||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
|
||||||
import { fieldHelpById } from "@repo/rdx-ui/lib/field-help";
|
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
@ -13,16 +7,20 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { CatalogTemplateCreateDialog } from "../../../catalog-templates";
|
import { CatalogTemplateCreateDialog } from "../../../catalog-templates";
|
||||||
import { InventoryFeedback } from "../../../shared";
|
import { InventoryFeedback } from "../../../shared";
|
||||||
|
import { ResourceCreateProgress } from "../../create/ui/resource-create-progress";
|
||||||
import { useResourceFormController } from "../controllers";
|
import { useResourceFormController } from "../controllers";
|
||||||
import { requireMailboxAddress } from "../helpers";
|
|
||||||
import type { ResourceFormProps } from "../types";
|
import type { ResourceFormProps } from "../types";
|
||||||
|
|
||||||
import { ResourceCharacteristicControl } from "./resource-characteristic-control";
|
import { ResourceFields } from "./resource-fields";
|
||||||
import { ResourceIntegrationFields } from "./resource-integration-fields";
|
import { ResourceFormActions } from "./resource-form-actions";
|
||||||
|
import { ResourceIdentityFields } from "./resource-identity-fields";
|
||||||
|
|
||||||
export function ResourceForm(props: ResourceFormProps) {
|
export function ResourceForm(props: ResourceFormProps) {
|
||||||
const { companyId, resource, items, allowQuickCreate = true } = props;
|
const { companyId, resource, items, allowQuickCreate = true } = props;
|
||||||
const {
|
const {
|
||||||
|
isWizard,
|
||||||
|
step,
|
||||||
|
previousStep,
|
||||||
creatingTemplate,
|
creatingTemplate,
|
||||||
setCreatingTemplate,
|
setCreatingTemplate,
|
||||||
referenceQuery,
|
referenceQuery,
|
||||||
@ -42,109 +40,71 @@ export function ResourceForm(props: ResourceFormProps) {
|
|||||||
} = useResourceFormController(props);
|
} = useResourceFormController(props);
|
||||||
const { t } = useTranslation("resources");
|
const { t } = useTranslation("resources");
|
||||||
const prefix = useId();
|
const prefix = useId();
|
||||||
|
const errorMessage = [
|
||||||
|
form.formState.errors.templateId?.message,
|
||||||
|
form.formState.errors.characteristics?.message,
|
||||||
|
form.formState.errors.integration?.message,
|
||||||
|
].find((message) => typeof message === "string");
|
||||||
|
|
||||||
const fieldHelp = fieldHelpById(items);
|
const layout = isWizard ? CREATION_STEP_LAYOUTS[step] : FULL_FORM_LAYOUT;
|
||||||
|
const notesMaxLength =
|
||||||
|
values.integration || template?.builtinKey === "template:mailbox" ? 4000 : 10000;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||||
<FormBody>
|
<FormBody>
|
||||||
|
{isWizard && <ResourceCreateProgress step={step} templateName={template?.values.name} />}
|
||||||
<fieldset className="space-y-5" disabled={mutation.isPending}>
|
<fieldset className="space-y-5" disabled={mutation.isPending}>
|
||||||
<FormSection title={t("formLayout.identity")}>
|
{layout.showIdentity && (
|
||||||
<div className="space-y-2">
|
<ResourceIdentityFields
|
||||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
allowQuickCreate={allowQuickCreate}
|
||||||
<Input id={`${prefix}-name`} maxLength={100} required {...form.register("name")} />
|
disabled={mutation.isPending}
|
||||||
</div>
|
form={form}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
mode={layout.identityMode}
|
||||||
<div className="space-y-2">
|
onCreateTemplate={() => setCreatingTemplate(true)}
|
||||||
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
|
onStatusChange={changeStatus}
|
||||||
<SelectWithAction
|
onTemplateChange={selectTemplate}
|
||||||
action={
|
prefix={prefix}
|
||||||
resource || !allowQuickCreate
|
resource={resource}
|
||||||
? undefined
|
templates={templates}
|
||||||
: { label: t("newTemplate"), onClick: () => setCreatingTemplate(true) }
|
values={values}
|
||||||
}
|
|
||||||
disabled={!!resource || mutation.isPending}
|
|
||||||
id={`${prefix}-template`}
|
|
||||||
label={t("template")}
|
|
||||||
onValueChange={selectTemplate}
|
|
||||||
options={
|
|
||||||
resource
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
value: resource.values.templateId,
|
|
||||||
label: resource.specification.templateName,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: templates.map((item) => ({ value: item.id, label: item.values.name }))
|
|
||||||
}
|
|
||||||
placeholder={t("chooseValue")}
|
|
||||||
required
|
|
||||||
value={values.templateId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`${prefix}-status`}>{t("status")}</Label>
|
|
||||||
<SelectWithAction
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
id={`${prefix}-status`}
|
|
||||||
label={t("status")}
|
|
||||||
onValueChange={changeStatus}
|
|
||||||
options={["active", "inactive"].map((value) => ({ value, label: t(value) }))}
|
|
||||||
placeholder={t("chooseValue")}
|
|
||||||
required
|
|
||||||
value={values.active ? "active" : "inactive"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormSection>
|
|
||||||
<ResourceIntegrationFields
|
|
||||||
fixed={!!resource}
|
|
||||||
integration={values.integration}
|
|
||||||
items={items}
|
|
||||||
onChange={(integration) =>
|
|
||||||
form.setValue("integration", integration, { shouldDirty: true })
|
|
||||||
}
|
|
||||||
references={referenceQuery.data?.references ?? []}
|
|
||||||
templateKey={template?.builtinKey}
|
|
||||||
/>
|
|
||||||
{fields.length > 0 && (
|
|
||||||
<section className="space-y-3">
|
|
||||||
<h3 className="font-medium">{t("characteristics")}</h3>
|
|
||||||
<FieldSections
|
|
||||||
hasErrors={!!mutation.error || Object.keys(form.formState.errors).length > 0}
|
|
||||||
items={fields}
|
|
||||||
renderItem={(field) => (
|
|
||||||
<ResourceCharacteristicControl
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
field={requireMailboxAddress(field, items, values, template)}
|
|
||||||
helpText={fieldHelp.get(field.fieldId)}
|
|
||||||
key={field.fieldId}
|
|
||||||
onChange={(value) => setCharacteristic(field.fieldId, value)}
|
|
||||||
prefix={prefix}
|
|
||||||
value={values.characteristics[field.fieldId]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
sections={sections}
|
|
||||||
ungroupedLabel={t("sections.ungrouped")}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
|
|
||||||
<Textarea
|
|
||||||
id={`${prefix}-notes`}
|
|
||||||
maxLength={
|
|
||||||
values.integration || template?.builtinKey === "template:mailbox" ? 4000 : 10000
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
{...form.register("notes")}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
{layout.showFields && (
|
||||||
|
<ResourceFields
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
fields={fields}
|
||||||
|
fixed={!!resource}
|
||||||
|
hasErrors={!!mutation.error || Object.keys(form.formState.errors).length > 0}
|
||||||
|
items={items}
|
||||||
|
onCharacteristicChange={setCharacteristic}
|
||||||
|
onIntegrationChange={(integration) => {
|
||||||
|
form.setValue("integration", integration, { shouldDirty: true });
|
||||||
|
form.clearErrors("integration");
|
||||||
|
}}
|
||||||
|
prefix={prefix}
|
||||||
|
references={referenceQuery.data?.references ?? []}
|
||||||
|
sections={sections}
|
||||||
|
template={template}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{layout.showNotes && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`${prefix}-notes`}
|
||||||
|
maxLength={notesMaxLength}
|
||||||
|
rows={3}
|
||||||
|
{...form.register("notes")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{Object.keys(form.formState.errors).length > 0 && (
|
{Object.keys(form.formState.errors).length > 0 && (
|
||||||
<p className="text-sm text-destructive" role="alert">
|
<p className="text-sm text-destructive" role="alert">
|
||||||
{t("inventoryErrors.INVALID_REQUEST")}
|
{errorMessage ?? t("inventoryErrors.INVALID_REQUEST")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<InventoryFeedback error={mutation.error ?? referenceQuery.error} />
|
<InventoryFeedback error={mutation.error ?? referenceQuery.error} />
|
||||||
@ -155,13 +115,15 @@ export function ResourceForm(props: ResourceFormProps) {
|
|||||||
guidance={t(`${helpPrefix}Guidance`)}
|
guidance={t(`${helpPrefix}Guidance`)}
|
||||||
scopeNote={t(`${helpPrefix}Scope`)}
|
scopeNote={t(`${helpPrefix}Scope`)}
|
||||||
title={t("helpTitle")}
|
title={t("helpTitle")}
|
||||||
|
variant="plain"
|
||||||
/>
|
/>
|
||||||
</FormBody>
|
</FormBody>
|
||||||
<FormActions>
|
<ResourceFormActions
|
||||||
<Button disabled={mutation.isPending} type="submit">
|
canContinue={!!template}
|
||||||
{t(mutation.isPending ? "saving" : "save")}
|
onPrevious={previousStep}
|
||||||
</Button>
|
pending={mutation.isPending}
|
||||||
</FormActions>
|
step={isWizard ? step : undefined}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
{creatingTemplate && (
|
{creatingTemplate && (
|
||||||
<CatalogTemplateCreateDialog
|
<CatalogTemplateCreateDialog
|
||||||
@ -178,3 +140,31 @@ export function ResourceForm(props: ResourceFormProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FULL_FORM_LAYOUT = {
|
||||||
|
showIdentity: true,
|
||||||
|
showFields: true,
|
||||||
|
showNotes: true,
|
||||||
|
identityMode: "all",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CREATION_STEP_LAYOUTS = {
|
||||||
|
1: {
|
||||||
|
showIdentity: true,
|
||||||
|
showFields: false,
|
||||||
|
showNotes: false,
|
||||||
|
identityMode: "template",
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
showIdentity: false,
|
||||||
|
showFields: true,
|
||||||
|
showNotes: false,
|
||||||
|
identityMode: "template",
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
showIdentity: true,
|
||||||
|
showFields: false,
|
||||||
|
showNotes: true,
|
||||||
|
identityMode: "identity",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|||||||
@ -0,0 +1,107 @@
|
|||||||
|
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||||
|
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||||
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { CatalogItemDTO, ResourceDTO, ResourceInput } from "../../../../common";
|
||||||
|
|
||||||
|
export function ResourceIdentityFields({
|
||||||
|
resource,
|
||||||
|
templates,
|
||||||
|
values,
|
||||||
|
form,
|
||||||
|
prefix,
|
||||||
|
mode,
|
||||||
|
disabled,
|
||||||
|
allowQuickCreate,
|
||||||
|
onCreateTemplate,
|
||||||
|
onTemplateChange,
|
||||||
|
onStatusChange,
|
||||||
|
}: {
|
||||||
|
resource: ResourceDTO | undefined;
|
||||||
|
templates: readonly CatalogItemDTO[];
|
||||||
|
values: ResourceInput;
|
||||||
|
form: UseFormReturn<ResourceInput>;
|
||||||
|
prefix: string;
|
||||||
|
mode: "all" | "template" | "identity";
|
||||||
|
disabled: boolean;
|
||||||
|
allowQuickCreate: boolean;
|
||||||
|
onCreateTemplate: () => void;
|
||||||
|
onTemplateChange: (id: string) => void;
|
||||||
|
onStatusChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("resources");
|
||||||
|
const options = resource
|
||||||
|
? [{ value: resource.values.templateId, label: resource.specification.templateName }]
|
||||||
|
: templates.map((item) => ({ value: item.id, label: item.values.name }));
|
||||||
|
const templateAction =
|
||||||
|
resource || !allowQuickCreate
|
||||||
|
? undefined
|
||||||
|
: { label: t("newTemplate"), onClick: onCreateTemplate };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection
|
||||||
|
title={t(mode === "template" ? "formLayout.template" : "formLayout.identity")}
|
||||||
|
variant="plain"
|
||||||
|
>
|
||||||
|
<div className={`grid gap-x-5 gap-y-4 ${IDENTITY_LAYOUTS[mode]}`}>
|
||||||
|
{mode !== "template" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||||
|
<Input
|
||||||
|
aria-describedby={mode === "identity" ? `${prefix}-name-help` : undefined}
|
||||||
|
id={`${prefix}-name`}
|
||||||
|
maxLength={100}
|
||||||
|
required
|
||||||
|
{...form.register("name")}
|
||||||
|
/>
|
||||||
|
{mode === "identity" && (
|
||||||
|
<p className="text-sm text-muted-foreground" id={`${prefix}-name-help`}>
|
||||||
|
{t("creationSteps.nameHelp")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode !== "identity" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
|
||||||
|
<SelectWithAction
|
||||||
|
action={templateAction}
|
||||||
|
disabled={!!resource || disabled}
|
||||||
|
id={`${prefix}-template`}
|
||||||
|
label={t("template")}
|
||||||
|
onValueChange={onTemplateChange}
|
||||||
|
options={options}
|
||||||
|
placeholder={t("chooseValue")}
|
||||||
|
required
|
||||||
|
value={values.templateId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode !== "template" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${prefix}-status`}>{t("status")}</Label>
|
||||||
|
<SelectWithAction
|
||||||
|
disabled={disabled}
|
||||||
|
id={`${prefix}-status`}
|
||||||
|
label={t("status")}
|
||||||
|
onValueChange={onStatusChange}
|
||||||
|
options={["active", "inactive"].map((value) => ({ value, label: t(value) }))}
|
||||||
|
placeholder={t("chooseValue")}
|
||||||
|
required
|
||||||
|
value={values.active ? "active" : "inactive"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDENTITY_LAYOUTS = {
|
||||||
|
all: "@min-[560px]:grid-cols-2 @min-[900px]:grid-cols-3",
|
||||||
|
template: "grid-cols-1",
|
||||||
|
identity: "@min-[560px]:grid-cols-[minmax(0,1fr)_180px]",
|
||||||
|
} as const;
|
||||||
Loading…
Reference in New Issue
Block a user