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