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:
David Arranz 2026-10-14 18:08:02 +02:00
parent 002781b56d
commit 2728692b98
24 changed files with 1111 additions and 216 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {
</Button>
)}
{query.data && (
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (asset) => asset.values.name,
hideable: false,
render: (asset) => (
<BrowserLink
aria-pressed={selected?.id === asset.id && detailOpen}
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"
href={entityHref(asset.id)}
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
);
<Tabs onValueChange={setTemplateId} value={templateId ?? "all"}>
<div className="min-w-0 overflow-x-auto">
<TabsList aria-label={t("filterByTemplate")} variant="line">
<TabsTrigger value="all">{t("allAssets")}</TabsTrigger>
{templates.map((template) => (
<TabsTrigger key={template.id} value={template.id}>
{template.values.name}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent keepMounted value={templateId ?? "all"}>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (asset) => asset.values.name,
hideable: false,
render: (asset) => (
<BrowserLink
aria-pressed={selected?.id === asset.id && detailOpen}
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"
href={entityHref(asset.id)}
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 (
<span className="inline-flex items-center gap-2">
<AssetTemplateIcon
icon={
template?.values.kind === "template" ? template.values.icon : undefined
}
/>
{asset.specification.templateName}
</span>
);
},
},
{
id: "state",
label: t("status"),
value: (asset) => t(asset.displayStatus),
render: (asset) => (
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
),
},
{
id: "site",
label: t("site"),
value: (asset) =>
lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
t("noAssignment"),
render: (asset) => (
<>
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
t("noAssignment")}
</>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (asset) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction
className="inline-flex inventory:hidden"
onClick={() => setViewId(asset.id)}
>
{t("viewAsset")}
</EntityAction>
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
{t("edit")}
</EntityAction>
<EntityAction
className="text-destructive data-highlighted:text-destructive"
onClick={() => setDeleteId(asset.id)}
>
{t("deleteAsset")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("noAssets")}
items={filtered}
locale={i18n.language}
onSearchChange={setSearch}
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
search={search}
searchPlaceholder={t("search")}
title={t("inventory")}
/>
return (
<span className="inline-flex items-center gap-2">
<AssetTemplateIcon
icon={
template?.values.kind === "template"
? template.values.icon
: undefined
}
/>
{asset.specification.templateName}
</span>
);
},
},
{
id: "state",
label: t("status"),
value: (asset) => t(asset.displayStatus),
render: (asset) => (
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
),
},
{
id: "site",
label: t("site"),
value: (asset) =>
lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
t("noAssignment"),
render: (asset) => (
<>
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
t("noAssignment")}
</>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (asset) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction
className="inline-flex inventory:hidden"
onClick={() => setViewId(asset.id)}
>
{t("viewAsset")}
</EntityAction>
<EntityAction
disabled={!catalog.data}
onClick={() => setEditor(asset.id)}
>
{t("edit")}
</EntityAction>
<EntityAction
className="text-destructive data-highlighted:text-destructive"
onClick={() => setDeleteId(asset.id)}
>
{t("deleteAsset")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
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")}
/>
</TabsContent>
</Tabs>
)}
</div>
<AssetSelectionPanel

View File

@ -12,7 +12,12 @@ import { I18nextProvider } from "react-i18next";
import { buildCatalogResponse } from "../../../api/application/asset-catalog-response.builder";
import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
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 { WorkstationSchema } from "../../../common/workstations";
import { AssetRelationships } from "../../asset-relationships";
@ -40,7 +45,8 @@ async function render(
language: string,
node: ReactNode,
relationships: RelationshipDTO[] = [],
workstations: WorkstationDTO[] = []
workstations: WorkstationDTO[] = [],
inventory: { assets: AssetDTO[]; catalog: CatalogResponseDTO } | undefined = undefined
) {
const i18n = createInstance();
@ -51,13 +57,14 @@ async function render(
const client = new QueryClient();
client.setQueryData(inventoryKeys.company(companyId), {
assets: [],
assets: inventory?.assets ?? [],
relationships,
workstations,
});
client.setQueryData(
catalogKeys.scope(companyId),
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
inventory?.catalog ??
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
);
try {
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.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(
@ -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 () => {
const html = await render(
"es",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<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) => {
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,

View File

@ -71,3 +71,56 @@ export function resourceDefaults(
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) ?? "";
}

View File

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

View File

@ -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,
<ResourceForm
companyId={companyId}
creationLayout="steps"
items={catalog.items}
onSaved={() => 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,
<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);
}
}
});

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<>
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
<FormBody>
{isWizard && <ResourceCreateProgress step={step} templateName={template?.values.name} />}
<fieldset className="space-y-5" disabled={mutation.isPending}>
<FormSection title={t("formLayout.identity")}>
<div className="space-y-2">
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
<Input id={`${prefix}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
<SelectWithAction
action={
resource || !allowQuickCreate
? undefined
: { label: t("newTemplate"), onClick: () => 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}
/>
</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")}
{layout.showIdentity && (
<ResourceIdentityFields
allowQuickCreate={allowQuickCreate}
disabled={mutation.isPending}
form={form}
mode={layout.identityMode}
onCreateTemplate={() => setCreatingTemplate(true)}
onStatusChange={changeStatus}
onTemplateChange={selectTemplate}
prefix={prefix}
resource={resource}
templates={templates}
values={values}
/>
</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>
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert">
{t("inventoryErrors.INVALID_REQUEST")}
{errorMessage ?? t("inventoryErrors.INVALID_REQUEST")}
</p>
)}
<InventoryFeedback error={mutation.error ?? referenceQuery.error} />
@ -155,13 +115,15 @@ export function ResourceForm(props: ResourceFormProps) {
guidance={t(`${helpPrefix}Guidance`)}
scopeNote={t(`${helpPrefix}Scope`)}
title={t("helpTitle")}
variant="plain"
/>
</FormBody>
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
<ResourceFormActions
canContinue={!!template}
onPrevious={previousStep}
pending={mutation.isPending}
step={isWizard ? step : undefined}
/>
</form>
{creatingTemplate && (
<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;

View File

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