feat: implement step-by-step asset creation process

- Added a new layout for asset creation that guides users through three steps: template selection, field completion, and final review.
- Introduced `AssetCreateProgress` component to display current step and instructions.
- Updated `AssetForm` to handle step navigation and validation.
- Integrated `FormStepActions` for navigation between steps and submission handling.
- Enhanced localization for new steps and descriptions in both English and Spanish.
- Refactored asset form components to separate identity and field sections for better clarity and usability.
- Added helper functions for validating required fields and suggesting asset names based on input.
This commit is contained in:
David Arranz 2026-10-16 10:55:12 +02:00
parent e042947992
commit 2149ecb17b
15 changed files with 747 additions and 203 deletions

View File

@ -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 ### Presentación compacta del alta por pasos
El alta de recursos usa `FormDialog size="medium"` para limitar el ancho a 768 px; El alta de recursos y el alta de activos desde el inventario de compañía usan
la edición completa conserva el tamaño grande. La cabecera aporta un icono decorativo de `FormDialog size="medium"` para limitar el ancho a 768 px; la edición completa conserva
recurso. El indicador numera los pasos, distingue el actual y marca los completados con 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. 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. 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. `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. 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 ## 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). 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).

View File

@ -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. - 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. 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 - 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. 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`. - Situar ayuda contextual general al final del cuerpo, inmediatamente antes de `FormActions`.

View File

@ -432,5 +432,19 @@
"confirm": "Select", "confirm": "Select",
"clear": "Clear selection", "clear": "Clear selection",
"required": "Required" "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."
} }
} }

View File

@ -432,5 +432,19 @@
"confirm": "Seleccionar", "confirm": "Seleccionar",
"clear": "Quitar selección", "clear": "Quitar selección",
"required": "Obligatorio" "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."
} }
} }

View File

@ -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 (
<div className="space-y-2">
<FormProgress
completedLabel={t("creationSteps.completed")}
description={t(`creationSteps.${keys[step - 1]}Description`)}
label={t("creationSteps.label")}
labels={keys.map((key) => t(`creationSteps.${key}`))}
progress={t("creationSteps.progress", { step, total: 3 })}
step={step}
/>
{templateName && step > 1 && (
<p className="text-sm text-muted-foreground">
{t("template")}: <span className="font-medium text-foreground">{templateName}</span>
</p>
)}
</div>
);
}

View File

@ -1,15 +1,27 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { FormEvent } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { AssetInput } from "../../../../common/inventory"; import type { AssetInput } from "../../../../common/inventory";
import { AssetInputSchema } from "../../../../common/inventory"; import { AssetInputSchema } from "../../../../common/inventory";
import { inventoryKeys } from "../../../shared/queries"; import { inventoryKeys } from "../../../shared/queries";
import { saveAsset } from "../api"; import { saveAsset } from "../api";
import { hasMissingAssetFields, suggestedAssetName } from "../helpers/asset-creation-values";
import type { AssetFormProps } from "../types"; 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 client = useQueryClient();
const [creatingTemplate, setCreatingTemplate] = useState(false); const [creatingTemplate, setCreatingTemplate] = useState(false);
const mounted = useRef(true); const mounted = useRef(true);
@ -87,11 +99,57 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
characteristics[fieldId] = value; characteristics[fieldId] = value;
} }
form.setValue("characteristics", characteristics, { shouldDirty: true }); form.setValue("characteristics", characteristics, { shouldDirty: true });
form.clearErrors("characteristics");
}; };
const submit = form.handleSubmit((input) => mutation.mutate(input)); const save = form.handleSubmit((input) => mutation.mutate(input));
const submit = async (event: FormEvent<HTMLFormElement>) => {
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) => { const selectTemplate = (templateId: string) => {
if (templateId === form.getValues("templateId")) {
return;
}
form.clearErrors();
form.setValue("templateId", templateId); form.setValue("templateId", templateId);
form.setValue("characteristics", {}); form.setValue("characteristics", {});
form.setValue("brandId", null); form.setValue("brandId", null);
@ -106,9 +164,7 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
}; };
const templateCreated = (id: string) => { const templateCreated = (id: string) => {
form.setValue("templateId", id); selectTemplate(id);
form.setValue("characteristics", {});
form.setValue("brandId", null);
setCreatingTemplate(false); setCreatingTemplate(false);
}; };
@ -119,6 +175,10 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
}; };
return { return {
isWizard,
step,
previousStep,
template,
creatingTemplate, creatingTemplate,
setCreatingTemplate, setCreatingTemplate,
form, form,

View File

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

View File

@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
import { Maybe } from "@repo/rdx-utils"; import { Maybe } from "@repo/rdx-utils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next"; import { createInstance } from "i18next";
@ -27,10 +28,12 @@ import { CatalogTemplateForm } from "../../catalog-templates";
import { assetsEnglish, assetsSpanish } from "../../shared"; import { assetsEnglish, assetsSpanish } from "../../shared";
import { inventoryKeys } from "../../shared/queries"; import { inventoryKeys } from "../../shared/queries";
import { WorkstationForm, WorkstationsPage } from "../../workstations"; import { WorkstationForm, WorkstationsPage } from "../../workstations";
import { AssetCreateProgress } from "../create/ui/asset-create-progress";
import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel"; import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
import { AssetInventoryPage } from "../list"; import { AssetInventoryPage } from "../list";
import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary"; import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary";
import { hasMissingAssetFields, suggestedAssetName } from "./helpers/asset-creation-values";
import { AssetForm } from "./ui"; import { AssetForm } from "./ui";
import { AssetCharacteristicControl } from "./ui/asset-characteristic-control"; import { AssetCharacteristicControl } from "./ui/asset-characteristic-control";
@ -427,6 +430,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
<AssetForm <AssetForm
asset={asset} asset={asset}
companyId={companyId} companyId={companyId}
creationLayout="steps"
items={[]} items={[]}
lookups={lookups} lookups={lookups}
onSaved={() => undefined} onSaved={() => 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(language === "es" ? "Fecha de alta" : "Registration date"));
assert.ok(form.includes('name="registeredOn"')); 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( const html = await render(
"es", "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,
<AssetForm
companyId={companyId}
creationLayout="steps"
items={catalog.items}
lookups={lookups}
onSaved={() => 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, /<button(?=[^>]*type="submit")(?=[^>]*disabled="")[^>]*>/);
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
assert.equal(html.includes("creationSteps."), false);
}
});
test("el progreso de activos conserva la plantilla y explica los pasos en es/en", async () => {
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,
<AssetCreateProgress step={step} templateName="Portátil" />
);
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",
<FormStepActions
canContinue
nextLabel="Siguiente"
onPrevious={() => 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(/<button[^>]*\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), "");
});

View File

@ -22,4 +22,5 @@ export type AssetFormProps = {
lookups: InventoryLookups; lookups: InventoryLookups;
onSaved: (id: string) => void; onSaved: (id: string) => void;
allowQuickCreate?: boolean; allowQuickCreate?: boolean;
creationLayout?: "single-page" | "steps";
}; };

View File

@ -1,4 +1,5 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { BoxIcon } from "@repo/rdx-ui/lib/icons";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -31,12 +32,18 @@ export function AssetEditorDialog({
} }
}} }}
open open
title={t(asset ? "editAsset" : "newAsset")} size={asset ? "large" : "medium"}
wide title={
<span className="flex items-center gap-3">
<BoxIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
{t(asset ? "editAsset" : "newAsset")}
</span>
}
> >
<AssetForm <AssetForm
asset={asset} asset={asset}
companyId={companyId} companyId={companyId}
creationLayout="steps"
items={items} items={items}
lookups={lookups} lookups={lookups}
onSaved={onSaved} onSaved={onSaved}

View File

@ -0,0 +1,94 @@
import { FieldSections } from "@repo/rdx-ui/components/field-sections";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import { TabbedFieldSections } from "@repo/rdx-ui/components/tabbed-field-sections";
import { fieldHelpById } from "@repo/rdx-ui/lib/field-help";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import type { AssetCharacteristic, AssetDTO, AssetInput } from "../../../../common/inventory";
import { BrandSelect } from "../../../brands";
import { AssetCharacteristicControl } from "./asset-characteristic-control";
export function AssetFields({
fields,
sections,
items,
values,
form,
prefix,
disabled,
allowQuickCreate,
brandFieldId,
onCharacteristicChange,
isWizard,
hasErrors,
}: {
fields: readonly AssetCharacteristic[];
sections: AssetDTO["specification"]["sections"];
items: readonly CatalogItemDTO[];
values: AssetInput;
form: UseFormReturn<AssetInput>;
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 (
<FormSection title={t("characteristics")} variant="plain">
<Sections
hasErrors={hasErrors}
items={fields}
key={values.templateId}
label={t("characteristics")}
layout="form"
renderItem={(field) =>
field.fieldId === brandFieldId ? (
<BrandSelect
allowQuickCreate={allowQuickCreate}
brandId={values.brandId ?? null}
disabled={disabled}
helpText={fieldHelp.get(field.fieldId)}
id={`${prefix}-${field.fieldId}`}
key={field.fieldId}
label={field.name}
legacyName={
typeof values.characteristics[field.fieldId] === "string"
? String(values.characteristics[field.fieldId])
: ""
}
onChange={(brand) => {
form.setValue("brandId", brand?.id ?? null, { shouldDirty: true });
onCharacteristicChange(field.fieldId, brand?.name);
}}
required={field.required}
/>
) : (
<AssetCharacteristicControl
disabled={disabled}
field={field}
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

@ -1,29 +1,28 @@
import { ContextHelp } from "@repo/rdx-ui/components/context-help"; 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 { FormLabel } from "@repo/rdx-ui/components/form-label";
import { FormSection } from "@repo/rdx-ui/components/form-section"; import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
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 { Textarea } from "@repo/shadcn-ui/components/textarea"; import { Textarea } from "@repo/shadcn-ui/components/textarea";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { useId } from "react"; import { useId } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AssetStatusSchema } from "../../../../common/inventory";
import { BrandSelect } from "../../../brands";
import { CatalogTemplateCreateDialog } from "../../../catalog-templates"; import { CatalogTemplateCreateDialog } from "../../../catalog-templates";
import { InventoryFeedback } from "../../../shared"; import { InventoryFeedback } from "../../../shared";
import { AssetCreateProgress } from "../../create/ui/asset-create-progress";
import { useAssetFormController } from "../controllers"; import { useAssetFormController } from "../controllers";
import type { AssetFormProps } from "../types"; 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) { export function AssetForm(props: AssetFormProps) {
const { companyId, asset, items, lookups, allowQuickCreate = true } = props; const { companyId, asset, items, lookups, allowQuickCreate = true } = props;
const { const {
isWizard,
step,
previousStep,
template,
creatingTemplate, creatingTemplate,
setCreatingTemplate, setCreatingTemplate,
form, form,
@ -42,7 +41,6 @@ export function AssetForm(props: AssetFormProps) {
} = useAssetFormController(props); } = useAssetFormController(props);
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
const prefix = useId(); const prefix = useId();
const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") };
const submitWithTabs = async (event: FormEvent<HTMLFormElement>) => { const submitWithTabs = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@ -56,6 +54,9 @@ export function AssetForm(props: AssetFormProps) {
return; return;
} }
await submit(event); await submit(event);
if (isWizard && step !== 3) {
return;
}
const invalidGeneralField = ( const invalidGeneralField = (
[ [
"name", "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 ( return (
<> <>
@ -82,170 +88,56 @@ export function AssetForm(props: AssetFormProps) {
onSubmit={submitWithTabs} onSubmit={submitWithTabs}
> >
<FormBody> <FormBody>
{isWizard && <AssetCreateProgress step={step} templateName={template?.values.name} />}
<fieldset className="space-y-5" disabled={mutation.isPending}> <fieldset className="space-y-5" disabled={mutation.isPending}>
<FormSection title={t("formLayout.identity")} variant="plain"> {layout.showIdentity && (
<div className="grid gap-x-5 gap-y-4 @min-[560px]:grid-cols-2 @min-[1100px]:grid-cols-4"> <AssetIdentityFields
<div className="space-y-2"> allowQuickCreate={allowQuickCreate}
<FormLabel htmlFor={`${prefix}-name`} required> asset={asset}
{t("name")} disabled={mutation.isPending}
</FormLabel> form={form}
<Input lookups={lookups}
id={`${prefix}-name`} mode={layout.identityMode}
maxLength={100} onCreateTemplate={() => setCreatingTemplate(true)}
required onSiteCreated={siteCreated}
{...form.register("name")} onStatusChange={changeStatus}
/> onTemplateChange={selectTemplate}
</div> prefix={prefix}
<div className="space-y-2"> templates={templates}
<FormLabel htmlFor={`${prefix}-template`} required> values={values}
{t("template")}
</FormLabel>
<SelectWithAction
action={
asset || !allowQuickCreate
? undefined
: { label: t("newTemplate"), onClick: () => 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}
/>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-status`} required>
{t("technicalStatus")}
</FormLabel>
<SelectWithAction
describedBy={`${prefix}-status-help`}
disabled={mutation.isPending}
id={`${prefix}-status`}
label={t("technicalStatus")}
onValueChange={changeStatus}
options={AssetStatusSchema.options.map((value) => ({
value,
label: t(value),
}))}
placeholder={t("chooseValue")}
required
value={values.status}
/>
<p className="text-xs text-muted-foreground" id={`${prefix}-status-help`}>
{t("technicalStatusHelp")}
</p>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-site`}>{t("site")}</FormLabel>
<SelectWithAction
{...selectProps}
action={
allowQuickCreate
? {
label: t("newSite"),
onClick: () => 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 ?? ""}
/>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-registered-on`}>{t("registeredOn")}</FormLabel>
<Input
aria-describedby={`${prefix}-registered-on-help`}
aria-invalid={!!form.formState.errors.registeredOn}
id={`${prefix}-registered-on`}
type="date"
{...form.register("registeredOn", { setValueAs: (value) => value || null })}
/>
<p className="text-xs text-muted-foreground" id={`${prefix}-registered-on-help`}>
{t("registeredOnHelp")}
</p>
{form.formState.errors.registeredOn && (
<p className="text-sm text-destructive" role="alert">
{t("inventoryErrors.INVALID_REQUEST")}
</p>
)}
</div>
</div>
</FormSection>
{fields.length > 0 && (
<FormSection title={t("characteristics")} variant="plain">
<TabbedFieldSections
items={fields}
key={values.templateId}
label={t("characteristics")}
renderItem={(field) =>
field.fieldId === brandFieldId ? (
<BrandSelect
allowQuickCreate={allowQuickCreate}
brandId={values.brandId ?? null}
disabled={mutation.isPending}
helpText={fieldHelp.get(field.fieldId)}
id={`${prefix}-${field.fieldId}`}
key={field.fieldId}
label={field.name}
legacyName={
typeof values.characteristics[field.fieldId] === "string"
? String(values.characteristics[field.fieldId])
: ""
}
onChange={(brand) => {
form.setValue("brandId", brand?.id ?? null, { shouldDirty: true });
setCharacteristic(field.fieldId, brand?.name);
}}
required={field.required}
/>
) : (
<AssetCharacteristicControl
disabled={mutation.isPending}
field={field}
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")}
/>
</FormSection>
)}
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-notes`}>{t("notes")}</FormLabel>
<Textarea
id={`${prefix}-notes`}
maxLength={10000}
rows={3}
{...form.register("notes")}
/> />
</div> )}
{layout.showFields && fields.length > 0 && (
<AssetFields
allowQuickCreate={allowQuickCreate}
brandFieldId={brandFieldId}
disabled={mutation.isPending}
fields={fields}
form={form}
hasErrors={Object.keys(form.formState.errors).length > 0 || !!mutation.error}
isWizard={isWizard}
items={items}
onCharacteristicChange={setCharacteristic}
prefix={prefix}
sections={sections}
values={values}
/>
)}
{layout.showNotes && (
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-notes`}>{t("notes")}</FormLabel>
<Textarea
id={`${prefix}-notes`}
maxLength={10000}
rows={3}
{...form.register("notes")}
/>
</div>
)}
</fieldset> </fieldset>
{Object.keys(form.formState.errors).length > 0 && ( {Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert"> <p className="text-sm text-destructive" role="alert">
{t("inventoryErrors.INVALID_REQUEST")} {errorMessage ?? t("inventoryErrors.INVALID_REQUEST")}
</p> </p>
)} )}
<InventoryFeedback error={mutation.error} /> <InventoryFeedback error={mutation.error} />
@ -259,11 +151,16 @@ export function AssetForm(props: AssetFormProps) {
variant="plain" variant="plain"
/> />
</FormBody> </FormBody>
<FormActions> <FormStepActions
<Button disabled={mutation.isPending} type="submit"> canContinue={!!template}
{t(mutation.isPending ? "saving" : "save")} nextLabel={t("creationSteps.next")}
</Button> onPrevious={previousStep}
</FormActions> pending={mutation.isPending}
previousLabel={t("creationSteps.previous")}
saveLabel={t(mutation.isPending ? "saving" : "save")}
step={isWizard ? step : undefined}
total={3}
/>
</form> </form>
{creatingTemplate && ( {creatingTemplate && (
<CatalogTemplateCreateDialog <CatalogTemplateCreateDialog
@ -280,3 +177,16 @@ export function AssetForm(props: AssetFormProps) {
</> </>
); );
} }
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,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<AssetInput>;
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 (
<FormSection
title={t(mode === "template" ? "formLayout.template" : "formLayout.identity")}
variant="plain"
>
<div
className={`grid gap-x-5 gap-y-4 ${mode === "template" ? "grid-cols-1" : "@min-[560px]:grid-cols-2 @min-[1100px]:grid-cols-4"}`}
>
{mode !== "template" && (
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-name`} required>
{t("name")}
</FormLabel>
<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">
<FormLabel htmlFor={`${prefix}-template`} required>
{t("template")}
</FormLabel>
<SelectWithAction
action={
asset || !allowQuickCreate
? undefined
: { label: t("newTemplate"), onClick: onCreateTemplate }
}
disabled={!!asset || disabled}
id={`${prefix}-template`}
label={t("template")}
onValueChange={onTemplateChange}
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}
/>
</div>
)}
{mode !== "template" && (
<>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-status`} required>
{t("technicalStatus")}
</FormLabel>
<SelectWithAction
describedBy={`${prefix}-status-help`}
disabled={disabled}
id={`${prefix}-status`}
label={t("technicalStatus")}
onValueChange={onStatusChange}
options={AssetStatusSchema.options.map((value) => ({
value,
label: t(value),
}))}
placeholder={t("chooseValue")}
required
value={values.status}
/>
<p className="text-xs text-muted-foreground" id={`${prefix}-status-help`}>
{t("technicalStatusHelp")}
</p>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-site`}>{t("site")}</FormLabel>
<SelectWithAction
{...selectProps}
action={
allowQuickCreate
? {
label: t("newSite"),
onClick: () => 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 ?? ""}
/>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-registered-on`}>{t("registeredOn")}</FormLabel>
<Input
aria-describedby={`${prefix}-registered-on-help`}
aria-invalid={!!form.formState.errors.registeredOn}
id={`${prefix}-registered-on`}
type="date"
{...form.register("registeredOn", { setValueAs: (value) => value || null })}
/>
<p className="text-xs text-muted-foreground" id={`${prefix}-registered-on-help`}>
{t("registeredOnHelp")}
</p>
{form.formState.errors.registeredOn && (
<p className="text-sm text-destructive" role="alert">
{t("inventoryErrors.INVALID_REQUEST")}
</p>
)}
</div>
</>
)}
</div>
</FormSection>
);
}

View File

@ -1,6 +1,4 @@
import { FormActions } from "@repo/rdx-ui/components/form-dialog"; import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
import { ChevronLeftIcon, ChevronRightIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export function ResourceFormActions({ export function ResourceFormActions({
@ -15,20 +13,17 @@ export function ResourceFormActions({
onPrevious: () => void; onPrevious: () => void;
}) { }) {
const { t } = useTranslation("resources"); const { t } = useTranslation("resources");
const label = step && step < 3 ? "creationSteps.next" : pending ? "saving" : "save";
return ( return (
<FormActions> <FormStepActions
{step && step > 1 && ( canContinue={canContinue}
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline"> nextLabel={t("creationSteps.next")}
<ChevronLeftIcon aria-hidden="true" className="size-4" /> onPrevious={onPrevious}
{t("creationSteps.previous")} pending={pending}
</Button> previousLabel={t("creationSteps.previous")}
)} saveLabel={t(pending ? "saving" : "save")}
<Button disabled={pending || (step === 1 && !canContinue)} type="submit"> step={step}
{t(label)} total={3}
{step && step < 3 && <ChevronRightIcon aria-hidden="true" className="size-4" />} />
</Button>
</FormActions>
); );
} }

View File

@ -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 (
<FormActions>
{step !== undefined && step > 1 && (
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{previousLabel}
</Button>
)}
<Button disabled={pending || (step === 1 && !canContinue)} type="submit">
{hasNext ? nextLabel : saveLabel}
{hasNext && <ChevronRightIcon aria-hidden="true" className="size-4" />}
</Button>
</FormActions>
);
}