diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md
index fda626e..5e987df 100644
--- a/docs/componentes-de-interfaz.md
+++ b/docs/componentes-de-interfaz.md
@@ -440,9 +440,9 @@ se añade una primitiva genérica de wizard sin otros consumidores. Véase
### 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
+El alta de recursos y el alta de activos desde el inventario de compañía usan
+`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 la entidad. 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.
@@ -455,9 +455,23 @@ comparten fila cuando hay espacio. Se conservan los márgenes del anillo de foco
`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.
+El alta de activos sigue tres pasos: plantilla, características e identificación/notas.
+Conserva marca y secciones propias de la plantilla; sede, estado técnico y fecha de alta
+se completan al final. Propone el primer campo de texto distinto de marca, o el nombre
+de la plantilla si no hay ninguno; un nombre editado manualmente se conserva al volver.
+Cambiar de plantilla reinicia características y marca; volver sin cambiarla conserva los valores.
+La edición y el alta anidada desde un puesto mantienen el formulario completo y sus pestañas.
+`AssetForm.creationLayout` activa los pasos únicamente al crear.
+
+`FormStepActions` de `@repo/rdx-ui/components/form-step-actions` comparte el pie entre
+recursos y activos: recibe paso/total, estado pendiente, disponibilidad de plantilla,
+callback de retroceso y etiquetas traducidas. Solo muestra Guardar al final (o en modo
+completo) y bloquea las acciones durante el envío; Cancelar lo aporta `FormDialog`.
+No conoce entidades, formularios, consultas ni endpoints.
+
## Progreso de formularios y alta de puestos
-`FormProgress` de `@repo/rdx-ui/components/form-progress` presenta pasos numerados, marca el actual con `aria-current="step"`, identifica los completados y lleva el foco al anuncio traducido de progreso. Recibe etiquetas, descripción y textos accesibles del consumidor; no conoce entidades ni consultas. Lo usan los asistentes de recursos y puestos.
+`FormProgress` de `@repo/rdx-ui/components/form-progress` presenta pasos numerados, marca el actual con `aria-current="step"`, identifica los completados y lleva el foco al anuncio traducido de progreso. Recibe etiquetas, descripción y textos accesibles del consumidor; no conoce entidades ni consultas. Lo usan los asistentes de recursos, activos y puestos.
El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y resumen. Los controles de asociaciones, composición, ordenador y resumen tienen responsabilidades propias y se reutilizan en la edición cuando corresponde. Las acciones Anterior/Siguiente conservan valores; Guardar aparece en el resumen. La sede única y el activo único tienen selección automática. Véase [Puestos de trabajo](puestos-de-trabajo.md#alta-en-cuatro-pasos).
diff --git a/docs/patron-de-dialogos.md b/docs/patron-de-dialogos.md
index ec52102..0a2dd2d 100644
--- a/docs/patron-de-dialogos.md
+++ b/docs/patron-de-dialogos.md
@@ -115,6 +115,11 @@ el [inventario de modales](inventario-de-modales.md) recoge las implementaciones
- Elegir la anchura del diálogo según su contenido y respetar los márgenes de la ventana.
Mantener cabecera y pie visibles y el cuerpo desplazable cuando el contenido lo requiera.
+- Las altas guiadas de recursos y activos desde el inventario de compañía siguen tres
+ pasos: plantilla, características y datos finales. Reutilizar `FormProgress` y
+ `FormStepActions`, validar antes de avanzar y conservar el borrador al retroceder.
+ Guardar se muestra al final; ayuda, cancelación y errores permanecen dentro del diálogo.
+ El alta usa tamaño medio y la edición completa conserva tamaño grande.
- Los formularios conservan altura según contenido; la altura estable por resultados es
la regla de los `PickerList`. No introducir pestañas ni pasos para resolver solo espacio.
- Situar ayuda contextual general al final del cuerpo, inmediatamente antes de `FormActions`.
diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json
index c37d1a2..4d0e1a9 100644
--- a/modules/assets/src/common/locales/en.json
+++ b/modules/assets/src/common/locales/en.json
@@ -432,5 +432,19 @@
"confirm": "Select",
"clear": "Clear selection",
"required": "Required"
+ },
+ "creationSteps": {
+ "label": "New asset steps",
+ "progress": "Step {{step}} of {{total}}",
+ "template": "Choose template",
+ "fields": "Complete fields",
+ "name": "Identification and notes",
+ "next": "Next",
+ "previous": "Back",
+ "nameHelp": "Name suggested from the asset fields. You can edit it.",
+ "completed": " (completed)",
+ "templateDescription": "Select the template that defines the asset characteristics.",
+ "fieldsDescription": "Complete the template details. Fields marked with * are required.",
+ "nameDescription": "Review the suggested name, technical status, site and registration date. Add notes if you need to provide more context."
}
}
diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json
index 22342d9..d1f3dd8 100644
--- a/modules/assets/src/common/locales/es.json
+++ b/modules/assets/src/common/locales/es.json
@@ -432,5 +432,19 @@
"confirm": "Seleccionar",
"clear": "Quitar selección",
"required": "Obligatorio"
+ },
+ "creationSteps": {
+ "label": "Pasos del nuevo activo",
+ "progress": "Paso {{step}} de {{total}}",
+ "template": "Elegir plantilla",
+ "fields": "Completar campos",
+ "name": "Identificación y notas",
+ "next": "Siguiente",
+ "previous": "Anterior",
+ "nameHelp": "Nombre propuesto a partir de los campos del activo. Puedes modificarlo.",
+ "completed": " (completado)",
+ "templateDescription": "Selecciona la plantilla que define las características del activo.",
+ "fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
+ "nameDescription": "Revisa el nombre propuesto, el estado técnico, la sede y la fecha de alta. Añade notas si necesitas aportar más contexto."
}
}
diff --git a/modules/assets/src/web/assets/create/ui/asset-create-progress.tsx b/modules/assets/src/web/assets/create/ui/asset-create-progress.tsx
new file mode 100644
index 0000000..a660b7b
--- /dev/null
+++ b/modules/assets/src/web/assets/create/ui/asset-create-progress.tsx
@@ -0,0 +1,31 @@
+import { FormProgress } from "@repo/rdx-ui/components/form-progress";
+import { useTranslation } from "react-i18next";
+
+export function AssetCreateProgress({
+ step,
+ templateName,
+}: {
+ step: 1 | 2 | 3;
+ templateName?: string | undefined;
+}) {
+ const { t } = useTranslation("assets");
+ const keys = ["template", "fields", "name"] as const;
+
+ return (
+
+
t(`creationSteps.${key}`))}
+ progress={t("creationSteps.progress", { step, total: 3 })}
+ step={step}
+ />
+ {templateName && step > 1 && (
+
+ {t("template")}: {templateName}
+
+ )}
+
+ );
+}
diff --git a/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts b/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts
index 3b5ab6c..c6a27e7 100644
--- a/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts
+++ b/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts
@@ -1,15 +1,27 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, 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";
import type { AssetInput } from "../../../../common/inventory";
import { AssetInputSchema } from "../../../../common/inventory";
import { inventoryKeys } from "../../../shared/queries";
import { saveAsset } from "../api";
+import { hasMissingAssetFields, suggestedAssetName } from "../helpers/asset-creation-values";
import type { AssetFormProps } from "../types";
-export function useAssetFormController({ companyId, asset, items, onSaved }: AssetFormProps) {
+export function useAssetFormController({
+ companyId,
+ asset,
+ items,
+ onSaved,
+ creationLayout = "single-page",
+}: AssetFormProps) {
+ const { t } = useTranslation("assets");
+ const isWizard = !asset && creationLayout === "steps";
+ const [step, setStep] = useState<1 | 2 | 3>(1);
const client = useQueryClient();
const [creatingTemplate, setCreatingTemplate] = useState(false);
const mounted = useRef(true);
@@ -87,11 +99,57 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
characteristics[fieldId] = value;
}
form.setValue("characteristics", characteristics, { shouldDirty: true });
+ form.clearErrors("characteristics");
};
- const submit = form.handleSubmit((input) => mutation.mutate(input));
+ const save = form.handleSubmit((input) => mutation.mutate(input));
+
+ const submit = async (event: FormEvent) => {
+ event.preventDefault();
+ if (mutation.isPending) {
+ return;
+ }
+ if (!isWizard || step === 3) {
+ await save(event);
+
+ return;
+ }
+ if (step === 1) {
+ if (template && (await form.trigger("templateId"))) {
+ setStep(2);
+ } else {
+ form.setError("templateId", { message: t("inventoryErrors.TEMPLATE_UNAVAILABLE") });
+ }
+
+ return;
+ }
+ if (hasMissingAssetFields(fields, form.getValues("characteristics"))) {
+ form.setError("characteristics", { message: t("inventoryErrors.REQUIRED_CHARACTERISTICS") });
+
+ return;
+ }
+ if (!(await form.trigger(["characteristics", "brandId"]))) {
+ return;
+ }
+ if (!form.formState.dirtyFields.name) {
+ form.setValue(
+ "name",
+ suggestedAssetName(template, fields, form.getValues("characteristics"), brandFieldId)
+ );
+ }
+ 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("brandId", null);
@@ -106,9 +164,7 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
};
const templateCreated = (id: string) => {
- form.setValue("templateId", id);
- form.setValue("characteristics", {});
- form.setValue("brandId", null);
+ selectTemplate(id);
setCreatingTemplate(false);
};
@@ -119,6 +175,10 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
};
return {
+ isWizard,
+ step,
+ previousStep,
+ template,
creatingTemplate,
setCreatingTemplate,
form,
diff --git a/modules/assets/src/web/assets/shared/helpers/asset-creation-values.ts b/modules/assets/src/web/assets/shared/helpers/asset-creation-values.ts
new file mode 100644
index 0000000..c6f1a22
--- /dev/null
+++ b/modules/assets/src/web/assets/shared/helpers/asset-creation-values.ts
@@ -0,0 +1,36 @@
+import type { CatalogItemDTO } from "../../../../common";
+import type { AssetCharacteristic, AssetInput } from "../../../../common/inventory";
+
+export function hasMissingAssetFields(
+ fields: readonly AssetCharacteristic[],
+ characteristics: AssetInput["characteristics"]
+) {
+ return fields.some((field) => {
+ const value = characteristics[field.fieldId];
+
+ return field.required && (value === undefined || (typeof value === "string" && !value.trim()));
+ });
+}
+
+/** Usa el primer texto propio de la plantilla y evita proponer solo el nombre de una marca. */
+export function suggestedAssetName(
+ template: CatalogItemDTO | undefined,
+ fields: readonly AssetCharacteristic[],
+ characteristics: AssetInput["characteristics"],
+ brandFieldId: string | undefined
+) {
+ for (const field of fields) {
+ const value = characteristics[field.fieldId];
+
+ if (
+ field.type === "text" &&
+ field.fieldId !== brandFieldId &&
+ typeof value === "string" &&
+ value.trim()
+ ) {
+ return value.trim().slice(0, 100);
+ }
+ }
+
+ return template?.values.name.slice(0, 100) ?? "";
+}
diff --git a/modules/assets/src/web/assets/shared/inventory.test.tsx b/modules/assets/src/web/assets/shared/inventory.test.tsx
index 6509c44..79cb9fc 100644
--- a/modules/assets/src/web/assets/shared/inventory.test.tsx
+++ b/modules/assets/src/web/assets/shared/inventory.test.tsx
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
+import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
import { Maybe } from "@repo/rdx-utils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
@@ -27,10 +28,12 @@ import { CatalogTemplateForm } from "../../catalog-templates";
import { assetsEnglish, assetsSpanish } from "../../shared";
import { inventoryKeys } from "../../shared/queries";
import { WorkstationForm, WorkstationsPage } from "../../workstations";
+import { AssetCreateProgress } from "../create/ui/asset-create-progress";
import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
import { AssetInventoryPage } from "../list";
import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary";
+import { hasMissingAssetFields, suggestedAssetName } from "./helpers/asset-creation-values";
import { AssetForm } from "./ui";
import { AssetCharacteristicControl } from "./ui/asset-characteristic-control";
@@ -427,6 +430,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
undefined}
@@ -435,6 +439,9 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
assert.ok(form.includes(language === "es" ? "Fecha de alta" : "Registration date"));
assert.ok(form.includes('name="registeredOn"'));
+ assert.ok(form.includes('name="name"'));
+ assert.ok(form.includes('name="notes"'));
+ assert.equal(form.includes('aria-current="step"'), false);
}
const html = await render(
"es",
@@ -638,3 +645,144 @@ test("el resumen usa los campos propios de la plantilla, omite vacíos y texto l
""
);
});
+
+test("alta de activo por pasos: empieza por plantilla, traduce la navegación y bloquea el avance sin selección", async () => {
+ const catalog = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none());
+
+ for (const language of ["es", "en"]) {
+ const html = await render(
+ language,
+ undefined}
+ />
+ );
+
+ assert.ok(html.includes(language === "es" ? "Paso 1 de 3" : "Step 1 of 3"));
+ assert.ok(html.includes(language === "es" ? "Siguiente" : "Next"));
+ assert.ok(html.includes('aria-current="step"'));
+ assert.ok(
+ html.includes(`aria-label="${language === "es" ? "Nueva plantilla" : "New template"}"`)
+ );
+ assert.equal(html.includes('name="name"'), false);
+ assert.equal(html.includes('name="notes"'), false);
+ assert.equal(html.includes('name="registeredOn"'), false);
+ assert.match(html, /]*type="submit")(?=[^>]*disabled="")[^>]*>/);
+ assert.ok(html.indexOf(" {
+ for (const { language, completed, fieldsDescription, nameDescription } of [
+ {
+ language: "es",
+ completed: "(completado)",
+ fieldsDescription: "Los campos marcados con * son obligatorios.",
+ nameDescription: "fecha de alta",
+ },
+ {
+ language: "en",
+ completed: "(completed)",
+ fieldsDescription: "Fields marked with * are required.",
+ nameDescription: "registration date",
+ },
+ ]) {
+ for (const step of [2, 3] as const) {
+ const html = await render(
+ language,
+
+ );
+
+ assert.ok(html.includes("Portátil"));
+ 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);
+ }
+ }
+});
+
+test("las acciones por pasos solo ofrecen guardar al final y bloquean acciones durante el envío", async () => {
+ for (const step of [1, 2, 3] as const) {
+ for (const pending of [false, true]) {
+ const html = await render(
+ "es",
+ undefined}
+ pending={pending}
+ previousLabel="Anterior"
+ saveLabel="Guardar"
+ step={step}
+ total={3}
+ />
+ );
+
+ assert.equal(html.includes("Anterior"), step > 1);
+ assert.equal(html.includes("Guardar"), step === 3);
+ assert.equal(html.includes("Siguiente"), step < 3);
+ assert.equal(
+ (html.match(/]*\sdisabled=""/g) ?? []).length,
+ pending ? (step > 1 ? 2 : 1) : 0
+ );
+ }
+ }
+});
+
+test("características obligatorias: rechaza ausentes y espacios y conserva cero y falso", () => {
+ const field = {
+ fieldId: companyId,
+ name: "Campo",
+ type: "text" as const,
+ unit: "",
+ choices: [],
+ required: true,
+ };
+
+ for (const characteristics of [{}, { [companyId]: "" }, { [companyId]: " " }]) {
+ assert.equal(hasMissingAssetFields([field], characteristics), true);
+ }
+ for (const value of [0, false, "Serie"]) {
+ assert.equal(hasMissingAssetFields([field], { [companyId]: value }), false);
+ }
+ assert.equal(hasMissingAssetFields([{ ...field, required: false }], {}), false);
+});
+
+test("nombre propuesto: usa texto de la plantilla, omite marca y vacíos y respeta el límite", () => {
+ const catalog = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none());
+ const template = catalog.items.find((item) => item.values.kind === "template");
+
+ assert.ok(template);
+ const fields = ["brand", "empty", "model"].map((fieldId) => ({
+ fieldId,
+ name: fieldId,
+ type: "text" as const,
+ unit: "",
+ choices: [],
+ required: false,
+ }));
+
+ assert.equal(
+ suggestedAssetName(
+ template,
+ fields,
+ { brand: "Marca", empty: " ", model: " Modelo " },
+ "brand"
+ ),
+ "Modelo"
+ );
+ assert.equal(
+ suggestedAssetName(template, fields, { brand: "Marca" }, "brand"),
+ template.values.name
+ );
+ assert.equal(
+ suggestedAssetName(template, fields, { model: "a".repeat(101) }, "brand").length,
+ 100
+ );
+ assert.equal(suggestedAssetName(undefined, fields, {}, undefined), "");
+});
diff --git a/modules/assets/src/web/assets/shared/types/asset-form-props.ts b/modules/assets/src/web/assets/shared/types/asset-form-props.ts
index 5c265e5..d09b1c9 100644
--- a/modules/assets/src/web/assets/shared/types/asset-form-props.ts
+++ b/modules/assets/src/web/assets/shared/types/asset-form-props.ts
@@ -22,4 +22,5 @@ export type AssetFormProps = {
lookups: InventoryLookups;
onSaved: (id: string) => void;
allowQuickCreate?: boolean;
+ creationLayout?: "single-page" | "steps";
};
diff --git a/modules/assets/src/web/assets/shared/ui/asset-editor-dialog.tsx b/modules/assets/src/web/assets/shared/ui/asset-editor-dialog.tsx
index 68bedd8..4ee4329 100644
--- a/modules/assets/src/web/assets/shared/ui/asset-editor-dialog.tsx
+++ b/modules/assets/src/web/assets/shared/ui/asset-editor-dialog.tsx
@@ -1,4 +1,5 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
+import { BoxIcon } from "@repo/rdx-ui/lib/icons";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@@ -31,12 +32,18 @@ export function AssetEditorDialog({
}
}}
open
- title={t(asset ? "editAsset" : "newAsset")}
- wide
+ size={asset ? "large" : "medium"}
+ title={
+
+
+ {t(asset ? "editAsset" : "newAsset")}
+
+ }
>
;
+ prefix: string;
+ disabled: boolean;
+ allowQuickCreate: boolean;
+ brandFieldId: string | undefined;
+ onCharacteristicChange: (
+ id: string,
+ value: AssetInput["characteristics"][string] | undefined
+ ) => void;
+ isWizard: boolean;
+ hasErrors: boolean;
+}) {
+ const { t } = useTranslation("assets");
+ const fieldHelp = fieldHelpById(items);
+ const Sections = isWizard ? FieldSections : TabbedFieldSections;
+
+ return (
+
+
+ field.fieldId === brandFieldId ? (
+ {
+ form.setValue("brandId", brand?.id ?? null, { shouldDirty: true });
+ onCharacteristicChange(field.fieldId, brand?.name);
+ }}
+ required={field.required}
+ />
+ ) : (
+ onCharacteristicChange(field.fieldId, value)}
+ prefix={prefix}
+ value={values.characteristics[field.fieldId]}
+ />
+ )
+ }
+ sections={sections}
+ ungroupedLabel={t("sections.ungrouped")}
+ />
+
+ );
+}
diff --git a/modules/assets/src/web/assets/shared/ui/asset-form.tsx b/modules/assets/src/web/assets/shared/ui/asset-form.tsx
index 9ece1e3..e841914 100644
--- a/modules/assets/src/web/assets/shared/ui/asset-form.tsx
+++ b/modules/assets/src/web/assets/shared/ui/asset-form.tsx
@@ -1,29 +1,28 @@
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
-import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
+import { FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormLabel } from "@repo/rdx-ui/components/form-label";
-import { FormSection } from "@repo/rdx-ui/components/form-section";
-import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
-import { TabbedFieldSections } from "@repo/rdx-ui/components/tabbed-field-sections";
-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 { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import type { FormEvent } from "react";
import { useId } from "react";
import { useTranslation } from "react-i18next";
-import { AssetStatusSchema } from "../../../../common/inventory";
-import { BrandSelect } from "../../../brands";
import { CatalogTemplateCreateDialog } from "../../../catalog-templates";
import { InventoryFeedback } from "../../../shared";
+import { AssetCreateProgress } from "../../create/ui/asset-create-progress";
import { useAssetFormController } from "../controllers";
import type { AssetFormProps } from "../types";
-import { AssetCharacteristicControl } from "./asset-characteristic-control";
+import { AssetFields } from "./asset-fields";
+import { AssetIdentityFields } from "./asset-identity-fields";
export function AssetForm(props: AssetFormProps) {
const { companyId, asset, items, lookups, allowQuickCreate = true } = props;
const {
+ isWizard,
+ step,
+ previousStep,
+ template,
creatingTemplate,
setCreatingTemplate,
form,
@@ -42,7 +41,6 @@ export function AssetForm(props: AssetFormProps) {
} = useAssetFormController(props);
const { t } = useTranslation("assets");
const prefix = useId();
- const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") };
const submitWithTabs = async (event: FormEvent) => {
event.preventDefault();
@@ -56,6 +54,9 @@ export function AssetForm(props: AssetFormProps) {
return;
}
await submit(event);
+ if (isWizard && step !== 3) {
+ return;
+ }
const invalidGeneralField = (
[
"name",
@@ -72,7 +73,12 @@ export function AssetForm(props: AssetFormProps) {
}
};
- const fieldHelp = fieldHelpById(items);
+ const errorMessage = [
+ form.formState.errors.templateId?.message,
+ form.formState.errors.characteristics?.message,
+ ].find((message) => typeof message === "string");
+
+ const layout = isWizard ? CREATION_STEP_LAYOUTS[step] : FULL_FORM_LAYOUT;
return (
<>
@@ -82,170 +88,56 @@ export function AssetForm(props: AssetFormProps) {
onSubmit={submitWithTabs}
>
+ {isWizard && }
-
-
-
-
- {t("name")}
-
-
-
-
-
- {t("template")}
-
- setCreatingTemplate(true) }
- }
- disabled={!!asset || mutation.isPending}
- id={`${prefix}-template`}
- label={t("template")}
- onValueChange={selectTemplate}
- options={
- asset
- ? [
- {
- value: asset.values.templateId,
- label: asset.specification.templateName,
- },
- ]
- : templates.map((item) => ({ value: item.id, label: item.values.name }))
- }
- placeholder={t("chooseValue")}
- required
- value={values.templateId}
- />
-
-
-
- {t("technicalStatus")}
-
-
({
- value,
- label: t(value),
- }))}
- placeholder={t("chooseValue")}
- required
- value={values.status}
- />
-
- {t("technicalStatusHelp")}
-
-
-
- {t("site")}
- lookups.onCreateSite(siteCreated),
- }
- : undefined
- }
- id={`${prefix}-site`}
- label={t("site")}
- onValueChange={(id) => form.setValue("siteId", id || null)}
- options={lookups.sites.map((site) => ({
- value: site.id,
- label: site.name,
- }))}
- value={values.siteId ?? ""}
- />
-
-
-
{t("registeredOn")}
-
value || null })}
- />
-
- {t("registeredOnHelp")}
-
- {form.formState.errors.registeredOn && (
-
- {t("inventoryErrors.INVALID_REQUEST")}
-
- )}
-
-
-
- {fields.length > 0 && (
-
-
- field.fieldId === brandFieldId ? (
- {
- form.setValue("brandId", brand?.id ?? null, { shouldDirty: true });
- setCharacteristic(field.fieldId, brand?.name);
- }}
- required={field.required}
- />
- ) : (
- setCharacteristic(field.fieldId, value)}
- prefix={prefix}
- value={values.characteristics[field.fieldId]}
- />
- )
- }
- sections={sections}
- ungroupedLabel={t("sections.ungrouped")}
- />
-
- )}
-
- {t("notes")}
-
+ )}
+ {layout.showFields && fields.length > 0 && (
+ 0 || !!mutation.error}
+ isWizard={isWizard}
+ items={items}
+ onCharacteristicChange={setCharacteristic}
+ prefix={prefix}
+ sections={sections}
+ values={values}
+ />
+ )}
+ {layout.showNotes && (
+
+ {t("notes")}
+
+
+ )}
{Object.keys(form.formState.errors).length > 0 && (
- {t("inventoryErrors.INVALID_REQUEST")}
+ {errorMessage ?? t("inventoryErrors.INVALID_REQUEST")}
)}
@@ -259,11 +151,16 @@ export function AssetForm(props: AssetFormProps) {
variant="plain"
/>
-
-
- {t(mutation.isPending ? "saving" : "save")}
-
-
+
{creatingTemplate && (
);
}
+
+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;
diff --git a/modules/assets/src/web/assets/shared/ui/asset-identity-fields.tsx b/modules/assets/src/web/assets/shared/ui/asset-identity-fields.tsx
new file mode 100644
index 0000000..f94f4b2
--- /dev/null
+++ b/modules/assets/src/web/assets/shared/ui/asset-identity-fields.tsx
@@ -0,0 +1,172 @@
+import { FormLabel } from "@repo/rdx-ui/components/form-label";
+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 type { UseFormReturn } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+import type { CatalogItemDTO } from "../../../../common";
+import type { AssetDTO, AssetInput } from "../../../../common/inventory";
+import { AssetStatusSchema } from "../../../../common/inventory";
+import type { InventoryLookups } from "../types";
+
+export function AssetIdentityFields({
+ asset,
+ templates,
+ values,
+ form,
+ prefix,
+ mode,
+ disabled,
+ allowQuickCreate,
+ lookups,
+ onCreateTemplate,
+ onTemplateChange,
+ onStatusChange,
+ onSiteCreated,
+}: {
+ asset: AssetDTO | undefined;
+ templates: readonly CatalogItemDTO[];
+ values: AssetInput;
+ form: UseFormReturn;
+ prefix: string;
+ mode: "all" | "template" | "identity";
+ disabled: boolean;
+ allowQuickCreate: boolean;
+ lookups: InventoryLookups;
+ onCreateTemplate: () => void;
+ onTemplateChange: (id: string) => void;
+ onStatusChange: (value: string) => void;
+ onSiteCreated: (id: string) => void;
+}) {
+ const { t } = useTranslation("assets");
+ const selectProps = { disabled, placeholder: t("noAssignment") };
+
+ return (
+
+
+ {mode !== "template" && (
+
+
+ {t("name")}
+
+
+ {mode === "identity" && (
+
+ {t("creationSteps.nameHelp")}
+
+ )}
+
+ )}
+ {mode !== "identity" && (
+
+
+ {t("template")}
+
+ ({ value: item.id, label: item.values.name }))
+ }
+ placeholder={t("chooseValue")}
+ required
+ value={values.templateId}
+ />
+
+ )}
+ {mode !== "template" && (
+ <>
+
+
+ {t("technicalStatus")}
+
+
({
+ value,
+ label: t(value),
+ }))}
+ placeholder={t("chooseValue")}
+ required
+ value={values.status}
+ />
+
+ {t("technicalStatusHelp")}
+
+
+
+ {t("site")}
+ lookups.onCreateSite(onSiteCreated),
+ }
+ : undefined
+ }
+ id={`${prefix}-site`}
+ label={t("site")}
+ onValueChange={(id) => form.setValue("siteId", id || null)}
+ options={lookups.sites.map((site) => ({
+ value: site.id,
+ label: site.name,
+ }))}
+ value={values.siteId ?? ""}
+ />
+
+
+
{t("registeredOn")}
+
value || null })}
+ />
+
+ {t("registeredOnHelp")}
+
+ {form.formState.errors.registeredOn && (
+
+ {t("inventoryErrors.INVALID_REQUEST")}
+
+ )}
+
+ >
+ )}
+
+
+ );
+}
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
index bb6161a..f78a5d7 100644
--- a/modules/resources/src/web/resources/shared/ui/resource-form-actions.tsx
+++ b/modules/resources/src/web/resources/shared/ui/resource-form-actions.tsx
@@ -1,6 +1,4 @@
-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 { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
import { useTranslation } from "react-i18next";
export function ResourceFormActions({
@@ -15,20 +13,17 @@ export function ResourceFormActions({
onPrevious: () => void;
}) {
const { t } = useTranslation("resources");
- const label = step && step < 3 ? "creationSteps.next" : pending ? "saving" : "save";
return (
-
- {step && step > 1 && (
-
-
- {t("creationSteps.previous")}
-
- )}
-
- {t(label)}
- {step && step < 3 && }
-
-
+
);
}
diff --git a/packages/rdx-ui/src/components/form-step-actions.tsx b/packages/rdx-ui/src/components/form-step-actions.tsx
new file mode 100644
index 0000000..7e64714
--- /dev/null
+++ b/packages/rdx-ui/src/components/form-step-actions.tsx
@@ -0,0 +1,43 @@
+import { Button } from "@repo/shadcn-ui/components/button";
+
+import { ChevronLeftIcon, ChevronRightIcon } from "../lib/icons";
+
+import { FormActions } from "./form-dialog";
+
+/** Acciones de formulario completo o por pasos; el consumidor aporta navegación y textos. */
+export function FormStepActions({
+ step,
+ total,
+ pending,
+ canContinue,
+ onPrevious,
+ previousLabel,
+ nextLabel,
+ saveLabel,
+}: {
+ step: number | undefined;
+ total: number;
+ pending: boolean;
+ canContinue: boolean;
+ onPrevious: () => void;
+ previousLabel: string;
+ nextLabel: string;
+ saveLabel: string;
+}) {
+ const hasNext = step !== undefined && step < total;
+
+ return (
+
+ {step !== undefined && step > 1 && (
+
+
+ {previousLabel}
+
+ )}
+
+ {hasNext ? nextLabel : saveLabel}
+ {hasNext && }
+
+
+ );
+}