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:
parent
e042947992
commit
2149ecb17b
@ -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).
|
||||||
|
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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) ?? "";
|
||||||
|
}
|
||||||
@ -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), "");
|
||||||
|
});
|
||||||
|
|||||||
@ -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";
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
94
modules/assets/src/web/assets/shared/ui/asset-fields.tsx
Normal file
94
modules/assets/src/web/assets/shared/ui/asset-fields.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
43
packages/rdx-ui/src/components/form-step-actions.tsx
Normal file
43
packages/rdx-ui/src/components/form-step-actions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user