diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 17f7b67..12c2adf 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -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. +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. 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 `/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. diff --git a/docs/inventario-de-activos.md b/docs/inventario-de-activos.md index 6fd305c..ee0b277 100644 --- a/docs/inventario-de-activos.md +++ b/docs/inventario-de-activos.md @@ -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. +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. 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. diff --git a/docs/recursos-campos-y-plantillas.md b/docs/recursos-campos-y-plantillas.md index 03a2906..43245c2 100644 --- a/docs/recursos-campos-y-plantillas.md +++ b/docs/recursos-campos-y-plantillas.md @@ -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 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. diff --git a/docs/rutas-del-cliente.md b/docs/rutas-del-cliente.md index 59e3865..a94018b 100644 --- a/docs/rutas-del-cliente.md +++ b/docs/rutas-del-cliente.md @@ -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`. - Tickets: `companyId`, `pendingOnly`, `q`, `state`, `priority` y `assignee`. - 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. - 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. diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index 73eaaf1..edc9534 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -86,6 +86,8 @@ "closeAssetPanel": "Close details panel", "showAssetPanel": "Show details panel", "inventory": "Assets", + "allAssets": "All", + "filterByTemplate": "Filter assets by template", "chooseValue": "Choose an option", "relatedNotice": "This item is saved separately and remains available if you later cancel the main form.", "newAsset": "New asset", diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 4f031de..3d69e63 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -86,6 +86,8 @@ "closeAssetPanel": "Cerrar ficha lateral", "showAssetPanel": "Mostrar ficha lateral", "inventory": "Activos", + "allAssets": "Todo", + "filterByTemplate": "Filtrar activos por plantilla", "chooseValue": "Selecciona una opción", "relatedNotice": "El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.", "newAsset": "Nuevo activo", diff --git a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts index e5159a5..d7c04c8 100644 --- a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts +++ b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts @@ -29,6 +29,26 @@ export function useAssetInventoryPageController({ }); const { searchParams, setSearchParams } = useBrowserSearchParams(); 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) => setSearchParams((params) => { @@ -65,6 +85,9 @@ export function useAssetInventoryPageController({ const viewed = assets.find((asset) => asset.id === viewId); const deleted = assets.find((asset) => asset.id === deleteId); const filtered = assets.filter((asset) => { + if (templateId && asset.values.templateId !== templateId) { + return false; + } const site = lookups.sites.find((site) => site.id === asset.values.siteId); return [ @@ -80,6 +103,10 @@ export function useAssetInventoryPageController({ entityHref, missingView: Boolean(selectedId && query.isSuccess && !selected), catalog, + templates, + templateId, + setTemplateId, + clearFilters, search, setSearch, editor, diff --git a/modules/assets/src/web/assets/list/ui/inventory-page.tsx b/modules/assets/src/web/assets/list/ui/inventory-page.tsx index 54f6b84..5b108a5 100644 --- a/modules/assets/src/web/assets/list/ui/inventory-page.tsx +++ b/modules/assets/src/web/assets/list/ui/inventory-page.tsx @@ -7,6 +7,7 @@ import { EntityTable, } from "@repo/rdx-ui/components/entity-list"; 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 { CatalogError } from "../../../catalog"; @@ -28,6 +29,10 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { const { query, catalog, + templates, + templateId, + setTemplateId, + clearFilters, search, setSearch, editor, @@ -98,105 +103,127 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { )} {query.data && ( - asset.values.name, - hideable: false, - render: (asset) => ( - setDetailOpen(true)} - > - {asset.values.name} - - ), - }, - { - id: "template", - label: t("template"), - value: (asset) => asset.specification.templateName, - render: (asset) => { - const template = catalog.data?.items.find( - (item) => item.id === asset.values.templateId - ); + +
+ + {t("allAssets")} + {templates.map((template) => ( + + {template.values.name} + + ))} + +
+ + asset.values.name, + hideable: false, + render: (asset) => ( + setDetailOpen(true)} + > + {asset.values.name} + + ), + }, + { + 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 ( - - - {asset.specification.templateName} - - ); - }, - }, - { - id: "state", - label: t("status"), - value: (asset) => t(asset.displayStatus), - render: (asset) => ( - - {t(asset.displayStatus)} - - ), - }, - { - 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: "actions", - label: t("actions"), - hideable: false, - render: (asset) => ( - - {" "} -
- setViewId(asset.id)} - > - {t("viewAsset")} - - setEditor(asset.id)}> - {t("edit")} - - setDeleteId(asset.id)} - > - {t("deleteAsset")} - -
{" "} -
- ), - }, - ]} - 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")} - /> + return ( + + + {asset.specification.templateName} + + ); + }, + }, + { + id: "state", + label: t("status"), + value: (asset) => t(asset.displayStatus), + render: (asset) => ( + + {t(asset.displayStatus)} + + ), + }, + { + 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: "actions", + label: t("actions"), + hideable: false, + render: (asset) => ( + + {" "} +
+ setViewId(asset.id)} + > + {t("viewAsset")} + + setEditor(asset.id)} + > + {t("edit")} + + setDeleteId(asset.id)} + > + {t("deleteAsset")} + +
{" "} +
+ ), + }, + ]} + emptyMessage={t("noAssets")} + filterKey={templateId ?? "all"} + 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")} + /> +
+
)} Todo<" : ">All<")); assert.ok(html.includes(language === "es" ? "Nuevo activo" : "New asset")); assert.ok( 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", + , + [], + [], + { assets, catalog } + ); + const table = html.match(//)?.[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 () => { const html = await render( "es", diff --git a/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts b/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts index df581de..db248cb 100644 --- a/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts +++ b/modules/companies/src/web/workspace/shared/helpers/workspace-routes.ts @@ -69,6 +69,7 @@ const RouteQuerySchema = z.strictObject({ closed: z.enum(["true", "false"]).optional(), contactsOnly: z.enum(["true", "false"]).optional(), q: z.string().max(200).optional(), + template: z.uuid().optional(), tab: z.enum(["templates", "fields", "relations"]).optional(), kind: AccessKindSchema.optional(), state: z.union([TicketStateSchema, InterventionStateSchema]).optional(), @@ -181,6 +182,9 @@ function queryMatchesLocation( if (location.area === "company" && query.companyId && query.companyId !== location.id) { return false; } + if (query.template && !(location.area === "company" && location.section === "inventory")) { + return false; + } return ( stateMatchesLocation(location, query.state) && diff --git a/modules/companies/src/web/workspace/shared/workspace-routes.test.ts b/modules/companies/src/web/workspace/shared/workspace-routes.test.ts index 01b6ace..9a9cf5f 100644 --- a/modules/companies/src/web/workspace/shared/workspace-routes.test.ts +++ b/modules/companies/src/web/workspace/shared/workspace-routes.test.ts @@ -10,6 +10,46 @@ import { const COMPANY_ID = "019b8838-44af-776d-a406-af74452b6f26"; 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", () => { 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", () => { const href = `/companies/${COMPANY_ID}/workstations/${ENTITY_ID}`; + assert.deepEqual(parseWorkspaceUrl(href), { area: "company", id: COMPANY_ID, diff --git a/modules/resources/src/common/locales/en.json b/modules/resources/src/common/locales/en.json index 2957f0c..88b37f0 100644 --- a/modules/resources/src/common/locales/en.json +++ b/modules/resources/src/common/locales/en.json @@ -161,5 +161,19 @@ "formLayout": { "identity": "Resource identification", "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." } } diff --git a/modules/resources/src/common/locales/es.json b/modules/resources/src/common/locales/es.json index d72cc48..03120f3 100644 --- a/modules/resources/src/common/locales/es.json +++ b/modules/resources/src/common/locales/es.json @@ -161,5 +161,19 @@ "formLayout": { "identity": "Identificación del recurso", "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." } } diff --git a/modules/resources/src/web/resources/create/ui/resource-create-progress.tsx b/modules/resources/src/web/resources/create/ui/resource-create-progress.tsx new file mode 100644 index 0000000..7043df6 --- /dev/null +++ b/modules/resources/src/web/resources/create/ui/resource-create-progress.tsx @@ -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 ( +
+
    + {STEP_KEYS.map((key, index) => { + const current = step === index + 1; + const completed = step > index + 1; + + return ( +
  1. + + + {t(`creationSteps.${key}`)} + {completed && {t("creationSteps.completed")}} + +
  2. + ); + })} +
+
+
+

+ {t(`creationSteps.${currentStep}`)} +

+

+ {t("creationSteps.progress", { step, total: 3 })} +

+
+

+ {t(`creationSteps.${currentStep}Description`)} +

+ {templateName && step > 1 && ( +

+ {t("template")}: {templateName} +

+ )} +
+
+ ); +} + +const STEP_KEYS = ["template", "fields", "name"] as const; + +function focusStep(element: HTMLParagraphElement | null) { + element?.focus(); +} diff --git a/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts b/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts index e88d695..0a21d81 100644 --- a/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts +++ b/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts @@ -1,5 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { FormEvent } from "react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -8,6 +9,7 @@ import type { ResourceInput } from "../../../../common/inventory"; import { ResourceInputSchema } from "../../../../common/inventory"; import { inventoryApi, inventoryKeys } from "../api"; import { mailboxIntegration, resourceDefaults, resourceFields } from "../helpers"; +import { hasMissingResourceFields, suggestedResourceName } from "../helpers/resource-form-values"; import type { ResourceFormProps } from "../types"; export function useResourceFormController({ @@ -18,10 +20,13 @@ export function useResourceFormController({ initialKind, initialAddress = "", initialIntegration, + creationLayout = "single-page", }: ResourceFormProps) { const { t } = useTranslation("resources"); const client = useQueryClient(); const [creatingTemplate, setCreatingTemplate] = useState(false); + const isWizard = !resource && creationLayout === "steps"; + const [step, setStep] = useState<1 | 2 | 3>(1); const mounted = useRef(true); useEffect(() => { @@ -100,9 +105,10 @@ export function useResourceFormController({ characteristics[fieldId] = value; } 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") { form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") }); @@ -116,7 +122,66 @@ export function useResourceFormController({ 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) => { + 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) => { + if (templateId === form.getValues("templateId")) { + return; + } + form.clearErrors(); form.setValue("templateId", templateId); form.setValue("characteristics", {}); form.setValue("integration", undefined); @@ -129,12 +194,14 @@ export function useResourceFormController({ }; const templateCreated = (id: string) => { - form.setValue("templateId", id); - form.setValue("characteristics", {}); + selectTemplate(id); setCreatingTemplate(false); }; return { + isWizard, + step, + previousStep, creatingTemplate, setCreatingTemplate, referenceQuery, diff --git a/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts b/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts index 52dfce2..a39c5a1 100644 --- a/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts +++ b/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts @@ -71,3 +71,56 @@ export function resourceDefaults( characteristics: addressField && initialAddress ? { [addressField.id]: initialAddress } : {}, }; } + +export function hasMissingResourceFields( + fields: ReturnType, + 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, + 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) ?? ""; +} diff --git a/modules/resources/src/web/resources/shared/resource-values.test.ts b/modules/resources/src/web/resources/shared/resource-values.test.ts index c28384d..806e588 100644 --- a/modules/resources/src/web/resources/shared/resource-values.test.ts +++ b/modules/resources/src/web/resources/shared/resource-values.test.ts @@ -1,7 +1,15 @@ import assert from "node:assert/strict"; 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 { hasMissingResourceFields, suggestedResourceName } from "./helpers/resource-form-values"; 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.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 + ); +}); diff --git a/modules/resources/src/web/resources/shared/resources.test.tsx b/modules/resources/src/web/resources/shared/resources.test.tsx index bbe6bc4..21372a0 100644 --- a/modules/resources/src/web/resources/shared/resources.test.tsx +++ b/modules/resources/src/web/resources/shared/resources.test.tsx @@ -14,6 +14,7 @@ import { ResourceCatalog } from "../../../api/domain/resource-catalog.aggregate" import { seedResourceCatalog } from "../../../api/domain/resource-catalog-seed"; import { catalogKeys } from "../../catalog/shared/api"; import { resourcesEnglish, resourcesSpanish } from "../../shared"; +import { ResourceCreateProgress } from "../create/ui/resource-create-progress"; import { ResourcesPage } from "../list"; import { inventoryKeys } from "./api"; @@ -124,6 +125,7 @@ test("licencia: formulario reutilizable con campos opcionales y ayuda antes de g language, undefined} 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.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, + 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, /]*type="submit")(?=[^>]*disabled="")[^>]*>/); + assert.ok(html.indexOf(" { + 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, + + ); + + 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); + } + } +}); diff --git a/modules/resources/src/web/resources/shared/types/resource-form-props.ts b/modules/resources/src/web/resources/shared/types/resource-form-props.ts index 1f7ff8f..8d57662 100644 --- a/modules/resources/src/web/resources/shared/types/resource-form-props.ts +++ b/modules/resources/src/web/resources/shared/types/resource-form-props.ts @@ -7,6 +7,7 @@ export type ResourceFormProps = { items: readonly CatalogItemDTO[]; onSaved: (id: string) => void; allowQuickCreate?: boolean; + creationLayout?: "single-page" | "steps"; initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | "os" | undefined; initialAddress?: string; initialIntegration?: ResourceInput["integration"]; diff --git a/modules/resources/src/web/resources/shared/ui/resource-editor-dialog.tsx b/modules/resources/src/web/resources/shared/ui/resource-editor-dialog.tsx index 1c670e1..9421db6 100644 --- a/modules/resources/src/web/resources/shared/ui/resource-editor-dialog.tsx +++ b/modules/resources/src/web/resources/shared/ui/resource-editor-dialog.tsx @@ -1,4 +1,5 @@ import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { LayersIcon } from "@repo/rdx-ui/lib/icons"; import type { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; @@ -29,10 +30,21 @@ export function ResourceEditorDialog({ } }} open - title={t(resource ? "editResource" : "newResource")} - wide + size={resource ? "large" : "medium"} + title={ + + + } > - + ); } diff --git a/modules/resources/src/web/resources/shared/ui/resource-fields.tsx b/modules/resources/src/web/resources/shared/ui/resource-fields.tsx new file mode 100644 index 0000000..f004aa0 --- /dev/null +++ b/modules/resources/src/web/resources/shared/ui/resource-fields.tsx @@ -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 ( + <> + + {fields.length > 0 && ( + + ( + onCharacteristicChange(field.fieldId, value)} + prefix={prefix} + value={values.characteristics[field.fieldId]} + /> + )} + sections={sections} + ungroupedLabel={t("sections.ungrouped")} + /> + + )} + + ); +} diff --git a/modules/resources/src/web/resources/shared/ui/resource-form-actions.tsx b/modules/resources/src/web/resources/shared/ui/resource-form-actions.tsx new file mode 100644 index 0000000..bb6161a --- /dev/null +++ b/modules/resources/src/web/resources/shared/ui/resource-form-actions.tsx @@ -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 ( + + {step && step > 1 && ( + + )} + + + ); +} diff --git a/modules/resources/src/web/resources/shared/ui/resource-form.tsx b/modules/resources/src/web/resources/shared/ui/resource-form.tsx index 9318b11..9cd3cb5 100644 --- a/modules/resources/src/web/resources/shared/ui/resource-form.tsx +++ b/modules/resources/src/web/resources/shared/ui/resource-form.tsx @@ -1,11 +1,5 @@ import { ContextHelp } from "@repo/rdx-ui/components/context-help"; -import { FieldSections } from "@repo/rdx-ui/components/field-sections"; -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 { FormBody } from "@repo/rdx-ui/components/form-dialog"; import { Label } from "@repo/shadcn-ui/components/label"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; import { useId } from "react"; @@ -13,16 +7,20 @@ import { useTranslation } from "react-i18next"; import { CatalogTemplateCreateDialog } from "../../../catalog-templates"; import { InventoryFeedback } from "../../../shared"; +import { ResourceCreateProgress } from "../../create/ui/resource-create-progress"; import { useResourceFormController } from "../controllers"; -import { requireMailboxAddress } from "../helpers"; import type { ResourceFormProps } from "../types"; -import { ResourceCharacteristicControl } from "./resource-characteristic-control"; -import { ResourceIntegrationFields } from "./resource-integration-fields"; +import { ResourceFields } from "./resource-fields"; +import { ResourceFormActions } from "./resource-form-actions"; +import { ResourceIdentityFields } from "./resource-identity-fields"; export function ResourceForm(props: ResourceFormProps) { const { companyId, resource, items, allowQuickCreate = true } = props; const { + isWizard, + step, + previousStep, creatingTemplate, setCreatingTemplate, referenceQuery, @@ -42,109 +40,71 @@ export function ResourceForm(props: ResourceFormProps) { } = useResourceFormController(props); const { t } = useTranslation("resources"); 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 ( <>
+ {isWizard && }
- -
- - -
-
-
- - setCreatingTemplate(true) } - } - 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} - /> -
-
- - ({ value, label: t(value) }))} - placeholder={t("chooseValue")} - required - value={values.active ? "active" : "inactive"} - /> -
-
-
- - form.setValue("integration", integration, { shouldDirty: true }) - } - references={referenceQuery.data?.references ?? []} - templateKey={template?.builtinKey} - /> - {fields.length > 0 && ( -
-

{t("characteristics")}

- 0} - items={fields} - renderItem={(field) => ( - setCharacteristic(field.fieldId, value)} - prefix={prefix} - value={values.characteristics[field.fieldId]} - /> - )} - sections={sections} - ungroupedLabel={t("sections.ungrouped")} - /> -
- )} -
- -