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

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

View File

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

View File

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

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

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 { 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
<AssetForm
asset={asset}
companyId={companyId}
creationLayout="steps"
items={[]}
lookups={lookups}
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('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,
<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;
onSaved: (id: string) => void;
allowQuickCreate?: boolean;
creationLayout?: "single-page" | "steps";
};

View File

@ -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={
<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
asset={asset}
companyId={companyId}
creationLayout="steps"
items={items}
lookups={lookups}
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 { 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<HTMLFormElement>) => {
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}
>
<FormBody>
{isWizard && <AssetCreateProgress step={step} templateName={template?.values.name} />}
<fieldset className="space-y-5" disabled={mutation.isPending}>
<FormSection title={t("formLayout.identity")} variant="plain">
<div className="grid gap-x-5 gap-y-4 @min-[560px]:grid-cols-2 @min-[1100px]:grid-cols-4">
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-name`} required>
{t("name")}
</FormLabel>
<Input
id={`${prefix}-name`}
maxLength={100}
required
{...form.register("name")}
/>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-template`} required>
{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")}
{layout.showIdentity && (
<AssetIdentityFields
allowQuickCreate={allowQuickCreate}
asset={asset}
disabled={mutation.isPending}
form={form}
lookups={lookups}
mode={layout.identityMode}
onCreateTemplate={() => setCreatingTemplate(true)}
onSiteCreated={siteCreated}
onStatusChange={changeStatus}
onTemplateChange={selectTemplate}
prefix={prefix}
templates={templates}
values={values}
/>
</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>
{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} />
@ -259,11 +151,16 @@ export function AssetForm(props: AssetFormProps) {
variant="plain"
/>
</FormBody>
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
<FormStepActions
canContinue={!!template}
nextLabel={t("creationSteps.next")}
onPrevious={previousStep}
pending={mutation.isPending}
previousLabel={t("creationSteps.previous")}
saveLabel={t(mutation.isPending ? "saving" : "save")}
step={isWizard ? step : undefined}
total={3}
/>
</form>
{creatingTemplate && (
<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 { 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 (
<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>
<FormStepActions
canContinue={canContinue}
nextLabel={t("creationSteps.next")}
onPrevious={onPrevious}
pending={pending}
previousLabel={t("creationSteps.previous")}
saveLabel={t(pending ? "saving" : "save")}
step={step}
total={3}
/>
);
}

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