Crear un cliente

This commit is contained in:
David Arranz 2026-06-28 20:04:46 +02:00
parent d5438e2f4e
commit 329bc6c98c
41 changed files with 646 additions and 749 deletions

View File

@ -8,7 +8,7 @@ export const CoreModuleManifest: IModuleClient = {
version: MODULE_VERSION,
dependencies: [],
protected: true,
layout: "app",
layout: "app-sidebar",
routes: (params: ModuleClientParams) => {
return [];
@ -18,3 +18,4 @@ export const CoreModuleManifest: IModuleClient = {
export default CoreModuleManifest;
export * from "./lib";
export * from "./utils";

View File

@ -0,0 +1 @@
export * from "./text-normalization-utils";

View File

@ -0,0 +1,21 @@
/**
* Normalizes a required text form value.
*
* It trims surrounding whitespace and always returns a string.
*/
export const toRequiredText = (value: string): string => {
return value.trim();
};
/**
* Normalizes a nullable text form value.
*
* Rules:
* - empty or whitespace-only string => null
* - otherwise => trimmed string
*/
export const toNullableText = (value: string): string | null => {
const trimmedValue = value.trim();
return trimmedValue === "" ? null : trimmedValue;
};

View File

@ -1,32 +1,45 @@
import {
CountryCodeSchema,
CurrencyCodeSchema,
EmailSchema,
LandPhoneSchema,
LanguageCodeSchema,
MobilePhoneSchema,
PostalCodeSchema,
TinSchema,
WebsiteSchema,
} from "@erp/core";
import { z } from "zod/v4";
export const CreateCustomerRequestSchema = z.object({
id: z.string().nonempty(),
reference: z.string().optional(),
is_company: z.boolean().default(true),
name: z.string(),
trade_name: z.string().optional(),
tin: z.string().optional(),
export const CreateCustomerAddressRequestSchema = z.object({
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().toLowerCase().default("es").optional(),
postal_code: PostalCodeSchema.optional(),
country: CountryCodeSchema,
});
email_primary: z.string().optional(),
email_secondary: z.string().optional(),
phone_primary: z.string().optional(),
phone_secondary: z.string().optional(),
mobile_primary: z.string().optional(),
mobile_secondary: z.string().optional(),
export const CreateCustomerContactRequestSchema = z.object({
email_primary: EmailSchema.optional(),
email_secondary: EmailSchema.optional(),
phone_primary: LandPhoneSchema.optional(),
phone_secondary: LandPhoneSchema.optional(),
mobile_primary: MobilePhoneSchema.optional(),
mobile_secondary: MobilePhoneSchema.optional(),
fax: LandPhoneSchema.optional(),
website: WebsiteSchema.optional(),
});
fax: z.string().optional(),
website: z.string().optional(),
export const CreateCustomerRequestSchema = z.object({
id: z.uuid(),
legal_record: z.string().optional(),
reference: z.string().optional(),
is_company: z.boolean(),
name: z.string(),
trade_name: z.string().optional(),
tin: TinSchema.optional(),
payment_method_id: z.uuid().nullable().optional(),
payment_term_id: z.uuid().nullable().optional(),
@ -34,8 +47,13 @@ export const CreateCustomerRequestSchema = z.object({
uses_equivalence_surcharge: z.boolean().default(false),
uses_retention: z.boolean().default(false),
language_code: z.string().toLowerCase().default("es"),
currency_code: z.string().toUpperCase().default("EUR"),
address: CreateCustomerAddressRequestSchema,
contact: CreateCustomerContactRequestSchema.optional(),
legal_record: z.string().optional(),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,
});
export type CreateCustomerRequestDTO = z.infer<typeof CreateCustomerRequestSchema>;

View File

@ -8,7 +8,7 @@ import {
MobilePhoneSchema,
PostalCodeSchema,
TinSchema,
URLSchema,
WebsiteSchema,
} from "@erp/core";
import { z } from "zod/v4";
@ -48,7 +48,7 @@ export const GetCustomerByIdResponseSchema = z.object({
mobile_secondary: MobilePhoneSchema.nullable(),
fax: LandPhoneSchema.nullable(),
website: URLSchema.nullable(),
website: WebsiteSchema.nullable(),
}),
legal_record: z.string().nullable(),

View File

@ -89,8 +89,18 @@
"success": {
"message": "The customer has been created successfully.",
"title": "Customer created"
},
"validation": {
"message": "There are validation errors. Fix the highlighted fields.",
"title": "Check the fields"
}
},
"header": {
"back": "Back",
"delete": "Delete",
"keyboard_shortcuts": "View keyboard shortcuts",
"modified": "Modified"
},
"title": "New customer"
},
"update": {

View File

@ -89,8 +89,18 @@
"success": {
"message": "Se ha creado el cliente correctamente.",
"title": "Cliente creado"
},
"validation": {
"message": "Hay errores de validación. Corrige los campos indicados.",
"title": "Revisa los campos"
}
},
"header": {
"back": "Volver",
"delete": "Eliminar",
"keyboard_shortcuts": "Ver atajos de teclado",
"modified": "Modificada"
},
"title": "Nuevo cliente"
},
"update": {

View File

@ -1,47 +0,0 @@
import type { Customer } from "../../shared";
import type { CustomerCreateForm } from "../entities";
/**
* Mapea un cliente a un formulario de creación de cliente.
* Es decir, adapta el shape de datos del dominio al shape de datos
* que necesita la UI para mostrar el formulario de creación.
*
* @param customer: El cliente que se va a adaptar al formulario de creación.
* @returns Un objeto con el shape de CustomerCreateForm,
* con los datos del cliente adaptados para mostrar
* en el formulario de creación.
*/
export const mapCustomerToCustomerCreateForm = (customer: Customer): CustomerCreateForm => {
return {
reference: customer.reference ?? "",
isCompany: customer.isCompany,
name: customer.name ?? "",
tradeName: customer.tradeName ?? "",
tin: customer.tin ?? "",
defaultTaxes: customer.defaultTaxes ?? [],
street: customer.street ?? "",
street2: customer.street2 ?? "",
city: customer.city ?? "",
province: customer.province ?? "",
postalCode: customer.postalCode ?? "",
country: customer.country ?? "es",
primaryEmail: customer.primaryEmail ?? "",
secondaryEmail: customer.secondaryEmail ?? "",
primaryPhone: customer.primaryPhone ?? "",
secondaryPhone: customer.secondaryPhone ?? "",
primaryMobile: customer.primaryMobile ?? "",
secondaryMobile: customer.secondaryMobile ?? "",
fax: customer.fax ?? "",
website: customer.website ?? "",
legalRecord: customer.legalRecord ?? "",
languageCode: customer.languageCode ?? "es",
currencyCode: customer.currencyCode ?? "EUR",
};
};

View File

@ -1 +0,0 @@
export * from "./customer-to-customer-create-form.adapter";

View File

@ -1,2 +1,3 @@
export * from "./use-customer-create.controller";
export * from "./use-customer-create-fiscal-controller";
export * from "./use-customer-create-page.controller";

View File

@ -0,0 +1,79 @@
import {
getPaymentMethodOptions,
usePaymentMethodsListQuery,
} from "@erp/catalogs/client/payment-methods";
import {
getPaymentTermOptions,
usePaymentTermsListQuery,
} from "@erp/catalogs/client/payment-terms";
import { getTaxRegimeOptions, useTaxRegimesListQuery } from "@erp/catalogs/client/tax-regimes";
import { useMemo } from "react";
export const useCustomerCreateFiscalController = () => {
const paymentMethodsQuery = usePaymentMethodsListQuery({
criteria: {
pageSize: 999,
filters: [
{
field: "isActive",
operator: "EQUALS",
value: "true",
},
],
},
});
const paymentTermsQuery = usePaymentTermsListQuery({
criteria: {
filters: [
{
field: "isActive",
operator: "EQUALS",
value: "true",
},
],
},
});
const taxRegimesQuery = useTaxRegimesListQuery({
criteria: {
filters: [
{
field: "isActive",
operator: "EQUALS",
value: "true",
},
],
},
});
const paymentMethodOptions = useMemo(() => {
return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []);
}, [paymentMethodsQuery.data?.items]);
const paymentTermOptions = useMemo(() => {
return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []);
}, [paymentTermsQuery.data?.items]);
const taxRegimeOptions = useMemo(() => {
return getTaxRegimeOptions(taxRegimesQuery.data?.items ?? []);
}, [taxRegimesQuery.data?.items]);
return {
paymentMethodOptions,
paymentTermOptions,
taxRegimeOptions,
isLoading: paymentMethodsQuery.isLoading || paymentTermsQuery.isLoading,
isFetching: paymentMethodsQuery.isFetching || paymentTermsQuery.isFetching,
isError: paymentMethodsQuery.isError || paymentTermsQuery.isError,
error: paymentMethodsQuery.error ?? paymentTermsQuery.error,
};
};
export type UseCustomerCreateFiscalControllerResult = ReturnType<
typeof useCustomerCreateFiscalController
>;

View File

@ -3,15 +3,17 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import { useCustomerCreateController } from "./use-customer-create.controller";
export const useCustomerCreatePageController = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo") ?? "/customers";
const navigate = useNavigate();
const createCtrl = useCustomerCreateController({
onCreated: (customer) => navigate(`/customers/${customer.id}`),
onCreated: (createdCustomer) => {
navigate(`/customers/${createdCustomer.id}/edit`);
},
});
const returnTo = searchParams.get("returnTo") ?? "/customers";
return {
createCtrl,
returnTo,

View File

@ -1,28 +1,49 @@
import { applyValidationErrorCollection } from "@erp/core";
import { formHasAnyDirty } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import {
UniqueID,
type ValidationErrorCollection,
isValidationErrorCollection,
} from "@repo/rdx-ddd";
import {
focusFirstInputFormError,
showErrorToast,
showSuccessToast,
showWarningToast,
} from "@repo/rdx-ui/helpers";
import { useId } from "react";
import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../i18n";
import type { CreateCustomerParams, Customer } from "../../shared";
import { useCustomerCreateMutation } from "../../shared/hooks/use-customer-create-mutation";
import { mapCustomerToCustomerCreateForm } from "../adapters";
import {
type CustomerCreateForm,
CustomerCreateFormSchema,
defaultCustomerCreateForm,
} from "../entities";
import { buildCreateCustomerParams } from "../utils";
import { type CreateCustomerParams, type Customer, useCustomerCreateMutation } from "../../shared";
import { type CustomerCreateForm, CustomerCreateFormSchema } from "../entities";
import { buildCreateCustomerParams, buildCustomerCreateDefault } from "../utils";
import { buildCustomerCreateData } from "../utils/build-customer-create-data";
import { useCustomerCreateFiscalController } from "./use-customer-create-fiscal-controller";
export interface UseCustomerCreateControllerOptions {
onCreated?(created: Customer): void;
successToasts?: boolean; // mostrar o no toast automáticcamente
successToasts?: boolean; // mostrar o no toast automáticamente
onError?(error: Error, inputPayload: CreateCustomerParams): void;
errorToasts?: boolean; // mostrar o no toast automáticcamente
onError?(error: Error, params: CreateCustomerParams): void;
errorToasts?: boolean; // mostrar o no toast automáticamente
}
export const useCustomerCreateController = (options?: UseCustomerCreateControllerOptions) => {
const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => {
if (isValidationErrorCollection(error)) {
return error;
}
if (error instanceof Error) {
return error;
}
return new Error("Unknown error");
};
export const useCreateCustomerController = (options?: UseCustomerCreateControllerOptions) => {
const { t } = useTranslation();
const formId = useId(); // id único por instancia
@ -37,29 +58,38 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
// 2) Form hook
const form = useHookForm<CustomerCreateForm>({
resolverSchema: CustomerCreateFormSchema,
initialValues: defaultCustomerCreateForm,
initialValues: buildCustomerCreateDefault(),
disabled: isCreating,
});
/** Handlers */
const resetForm = () => {
form.reset(defaultCustomerCreateForm, { keepDirty: false });
form.reset(buildCustomerCreateDefault(), { keepDirty: false });
};
const submitHandler = form.handleSubmit(
async (formData: CustomerCreateForm) => {
const params: CreateCustomerParams = buildCreateCustomerParams(formData);
if (!formHasAnyDirty(form.formState.dirtyFields)) {
showWarningToast(
t("create.feedback.no_changes.title"),
t("create.feedback.no_changes.message")
);
return;
}
const data = buildCustomerCreateData(formData);
const customerId = UniqueID.generateNewID().toString();
const params = buildCreateCustomerParams(customerId, data);
console.log("Enviando creación con params:", params);
try {
// Enviamos cambios al servidor
const created = await mutateAsync(params); // payload es CustomerCreatePayload
const created = await mutateAsync(params);
// Ha ido bien -> actualizamos form con datos reales
// Ha ido bien -> actualizamos form con datos por defecto
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
form.reset(mapCustomerToCustomerCreateForm(created), {
keepDirty: false,
});
resetForm();
if (options?.successToasts !== false) {
showSuccessToast(
@ -69,22 +99,45 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
}
options?.onCreated?.(created);
} catch (error: unknown) {
const normalizedError =
error instanceof Error ? error : new Error(t("create.errors.unknown"));
console.log(error);
const normalizedError = normalizeSubmitError(error);
console.debug(normalizedError);
// No revierto el form para que no se pierdan los cambios que
// ha hecho el usuario y no han sido guardados.
/*resetForm()*/
if (isValidationErrorCollection(normalizedError)) {
applyValidationErrorCollection(form, normalizedError);
console.log("Errores de validación aplicados al form:", form.formState.errors);
focusFirstInputFormError(form);
if (options?.errorToasts !== false) {
showErrorToast(t("create.errors.title"), normalizedError.message);
showWarningToast(
t("create.feedback.validation.title"),
t("create.feedback.validation.message")
);
}
options?.onError?.(normalizedError, params);
return;
}
if (options?.errorToasts !== false) {
showErrorToast(
t("create.errors.title"),
normalizedError.message || t("create.errors.unknown")
);
}
options?.onError?.(normalizedError, params);
}
},
(errors: FieldErrors<CustomerCreateForm>) => {
const firstKey = Object.keys(errors)[0] as keyof CustomerCreateForm | undefined;
if (firstKey) {
document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`)?.focus();
}
console.error(errors);
focusFirstInputFormError(form);
showWarningToast(
t("common.feedback.validation.title"),
@ -94,11 +147,13 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
);
// Evento onSubmit ya preparado para el <form>
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const onSubmit = (event: React.SubmitEvent<HTMLFormElement>) => {
event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
submitHandler(event);
};
const fiscalCtrl = useCustomerCreateFiscalController();
return {
// form
form,
@ -113,8 +168,13 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
isCreateError,
createError,
// Otros controladores
fiscalCtrl,
// No devolver FormProvider, así el controller es más
// flexible y reusable (p.ej. para un modal)
// FormProvider,
};
};
export const useCustomerCreateController = useCreateCustomerController;

View File

@ -0,0 +1,46 @@
/**
* CustomerCreateData representa los datos normalizados de creación
* antes de construir el contrato de API.
*
* Reglas:
* - mantiene naming camelCase porque sigue siendo contrato frontend.
* - los selects opcionales sin selección quedan como undefined.
* - no es DTO backend.
*/
export type CustomerCreateData = {
isCompany: boolean;
name: string;
tradeName?: string;
tin?: string;
reference?: string;
street?: string;
street2?: string;
city?: string;
province?: string;
postalCode?: string;
country: string;
primaryEmail?: string;
secondaryEmail?: string;
primaryPhone?: string;
secondaryPhone?: string;
primaryMobile?: string;
secondaryMobile?: string;
fax?: string;
website?: string;
legalRecord?: string;
paymentMethodId?: string;
paymentTermId?: string;
taxRegimeCode?: string;
usesEquivalenceSurcharge: boolean;
usesRetention: boolean;
languageCode: string;
currencyCode: string;
};

View File

@ -39,6 +39,13 @@ export interface CustomerCreateForm {
legalRecord: string;
paymentMethodId: string;
paymentTermId: string;
taxRegimeCode: string;
usesEquivalenceSurcharge: boolean;
usesRetention: boolean;
languageCode: string;
currencyCode: string;
}

View File

@ -24,12 +24,13 @@ import { z } from "zod/v4";
* - sin detalles impuestos por el widget
*/
export const CustomerCreateFormSchema = z.object({
export const CustomerCreateFormSchema = z.object(
{
reference: z.string(),
isCompany: z.boolean(),
name: z.string().min(1, "El nombre es obligatorio"),
tradeName: z.string(),
tradeName: z.string().or(z.literal("")),
tin: TinSchema.or(z.literal("")),
street: z.string(),
@ -52,6 +53,17 @@ export const CustomerCreateFormSchema = z.object({
legalRecord: z.string().or(z.literal("")),
paymentMethodId: z.string().or(z.literal("")),
paymentTermId: z.string().or(z.literal("")),
taxRegimeCode: z.string().or(z.literal("")),
usesEquivalenceSurcharge: z.boolean(),
usesRetention: z.boolean(),
languageCode: LanguageCodeSchema,
currencyCode: CurrencyCodeSchema,
});
},
{
error: "error de esquema",
}
);

View File

@ -1,3 +1,3 @@
export * from "./customer-create-data.entity";
export * from "./customer-create-form.entity";
export * from "./customer-create-form.schema";
export * from "./customer-create-form-default.entity";

View File

@ -0,0 +1,32 @@
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../i18n";
export const CustomerEditorSkeleton = () => {
const { t } = useTranslation();
return (
<>
<AppContent>
<div className="flex items-center justify-between">
<div aria-hidden="true" className="space-y-2">
<div className="h-7 w-64 rounded-md bg-muted animate-pulse" />
<div className="h-5 w-96 rounded-md bg-muted animate-pulse" />
</div>
<div className="flex items-center gap-2">
<BackHistoryButton />
<Button aria-busy disabled>
{t("update.actions.submit")}
</Button>
</div>
</div>
<div aria-hidden="true" className="mt-6 grid gap-4">
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
<div className="h-28 w-full rounded-md bg-muted animate-pulse" />
</div>
<span className="sr-only">{t("update.loading")}</span>
</AppContent>
</>
);
};

View File

@ -0,0 +1 @@
export * from "./customer-editor-skeleton";

View File

@ -1,53 +0,0 @@
import { SelectField } from "@repo/rdx-ui/components";
import {
Field,
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../i18n";
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../../shared";
interface CustomerAdditionalConfigFieldsProps {
className?: string;
}
export const CustomerAdditionalConfigFields = ({
className,
...props
}: CustomerAdditionalConfigFieldsProps) => {
const { t } = useTranslation();
return (
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.preferences.title")}</FieldLegend>
<FieldDescription>{t("form_groups.preferences.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
<Field className="lg:col-span-2">
<SelectField
description={t("fields.language_code.description")}
items={[...LANGUAGE_OPTIONS]}
label={t("fields.language_code.label")}
name="languageCode"
placeholder={t("fields.language_code.placeholder")}
required
/>
</Field>
<Field className="lg:col-span-2">
<SelectField
className="lg:col-span-2"
description={t("fields.currency_code.description")}
items={[...CURRENCY_OPTIONS]}
label={t("fields.currency_code.label")}
name="currencyCode"
placeholder={t("fields.currency_code.placeholder")}
required
/>
</Field>
</FieldGroup>
</FieldSet>
);
};

View File

@ -1,76 +0,0 @@
import { SelectField, TextField } from "@repo/rdx-ui/components";
import {
Field,
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../i18n";
import { COUNTRY_OPTIONS } from "../../../shared";
interface CustomerAddressFieldsProps {
className?: string;
}
export const CustomerAddressFields = ({ className, ...props }: CustomerAddressFieldsProps) => {
const { t } = useTranslation();
return (
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.address.title")}</FieldLegend>
<FieldDescription>{t("form_groups.address.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
<TextField
className="lg:col-span-2"
description={t("fields.street.description")}
label={t("fields.street.label")}
name="street"
placeholder={t("fields.street.placeholder")}
/>
<TextField
className="lg:col-span-2"
description={t("fields.street2.description")}
label={t("fields.street2.label")}
name="street2"
placeholder={t("fields.street2.placeholder")}
/>
<TextField
className="lg:col-span-2"
description={t("fields.city.description")}
label={t("fields.city.label")}
name="city"
placeholder={t("fields.city.placeholder")}
/>
<TextField
description={t("fields.postal_code.description")}
label={t("fields.postal_code.label")}
name="postalCode"
placeholder={t("fields.postal_code.placeholder")}
/>
<Field className="lg:col-span-2 lg:col-start-1">
<TextField
description={t("fields.province.description")}
label={t("fields.province.label")}
name="province"
placeholder={t("fields.province.placeholder")}
/>
</Field>
<Field className="lg:col-span-2">
<SelectField
description={t("fields.country.description")}
items={[...COUNTRY_OPTIONS]}
label={t("fields.country.label")}
name="country"
placeholder={t("fields.country.placeholder")}
required
/>
</Field>
</FieldGroup>
</FieldSet>
);
};

View File

@ -1,140 +0,0 @@
import { FormFieldLabel, TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import type { CustomerCreateForm } from "../../entities";
interface CustomerBasicInfoFieldsProps extends React.ComponentProps<typeof FieldSet> {}
export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicInfoFieldsProps) => {
const { t } = useTranslation();
const { control, setFocus } = useFormContext<CustomerCreateForm>();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
<TextField
className="lg:col-span-2"
description={t("fields.name.description")}
label={t("fields.name.label")}
name="name"
placeholder={t("fields.name.placeholder")}
required
/>
<Controller
control={control}
name="isCompany"
render={({ field, fieldState }) => {
return (
<Field
className="gap-1 lg:col-span-1 lg:col-start-1"
data-invalid={fieldState.invalid}
>
<FormFieldLabel required>{t("fields.customer_type.label")}</FormFieldLabel>
<RadioGroup
className="gap-3"
disabled={field.disabled}
name={field.name}
onValueChange={(value) => {
field.onChange(value === "company");
}}
required
value={field.value ? "company" : "individual"}
>
<Field data-invalid={fieldState.invalid} orientation="horizontal">
<FieldContent>
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="customer-type-company"
value="company"
/>
<FieldLabel
className="cursor-pointer text-sm font-medium leading-none"
htmlFor="customer-type-company"
>
{t("fields.customer_type.options.company")}
</FieldLabel>
</FieldContent>
</Field>
<Field data-invalid={fieldState.invalid} orientation="horizontal">
<FieldContent>
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="customer-type-individual"
value="individual"
/>
<FieldLabel
className="cursor-pointer text-sm font-medium leading-none"
htmlFor="customer-type-individual"
>
{t("fields.customer_type.options.individual")}
</FieldLabel>
</FieldContent>
</Field>
</RadioGroup>
<FieldDescription>{t("fields.customer_type.description")}</FieldDescription>
<FieldError errors={[fieldState.error]} />
</Field>
);
}}
/>
<TextField
className="lg:col-span-1"
description={t("fields.tin.description")}
label={t("fields.tin.label")}
name="tin"
placeholder={t("fields.tin.placeholder")}
/>
<TextField
className="lg:col-span-full"
description={t("fields.trade_name.description")}
label={t("fields.trade_name.label")}
name="tradeName"
placeholder={t("fields.trade_name.placeholder")}
/>
<TextField
className="lg:col-span-2 lg:col-start-1"
description={t("fields.reference.description")}
label={t("fields.reference.label")}
name="reference"
placeholder={t("fields.reference.placeholder")}
/>
<TextAreaField
className="lg:col-span-full"
description={t("fields.legal_record.description")}
label={t("fields.legal_record.label")}
name="legalRecord"
placeholder={t("fields.legal_record.placeholder")}
/>
</FieldGroup>
</FieldSet>
);
};

View File

@ -1,155 +0,0 @@
import { TextField } from "@repo/rdx-ui/components";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Field,
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
Separator,
} from "@repo/shadcn-ui/components";
import { AtSignIcon, ChevronDown, GlobeIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "../../../i18n";
interface CustomerContactFieldsProps {
className?: string;
}
export const CustomerContactFields = ({ className, ...props }: CustomerContactFieldsProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
return (
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.contact_info.title")}</FieldLegend>
<FieldDescription>{t("form_groups.contact_info.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
<TextField
className="lg:col-span-2"
description={t("fields.email_primary.description")}
label={t("fields.email_primary.label")}
leftIcon={
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
name="primaryEmail"
placeholder={t("fields.email_primary.placeholder")}
typePreset="email"
/>
<TextField
className="lg:col-span-2"
description={t("fields.mobile_primary.description")}
label={t("fields.mobile_primary.label")}
leftIcon={
<SmartphoneIcon
className="h-[18px] w-[18px] text-muted-foreground"
strokeWidth={1.5}
/>
}
name="primaryMobile"
placeholder={t("fields.mobile_primary.placeholder")}
typePreset="phone"
/>
<TextField
className="lg:col-span-2"
description={t("fields.phone_primary.description")}
label={t("fields.phone_primary.label")}
leftIcon={
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
name="primaryPhone"
placeholder={t("fields.phone_primary.placeholder")}
typePreset="phone"
/>
</FieldGroup>
<Separator className="mt-6" />
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
<TextField
className="lg:col-span-2 lg:col-start-1"
description={t("fields.email_secondary.description")}
label={t("fields.email_secondary.label")}
leftIcon={
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
name="secondaryEmail"
placeholder={t("fields.email_secondary.placeholder")}
typePreset="email"
/>
<TextField
className="lg:col-span-2"
description={t("fields.mobile_secondary.description")}
label={t("fields.mobile_secondary.label")}
leftIcon={
<SmartphoneIcon
className="h-[18px] w-[18px] text-muted-foreground"
strokeWidth={1.5}
/>
}
name="secondaryMobile"
placeholder={t("fields.mobile_secondary.placeholder")}
typePreset="phone"
/>
<TextField
className="lg:col-span-2"
description={t("fields.phone_secondary.description")}
label={t("fields.phone_secondary.label")}
leftIcon={
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
}
name="secondaryPhone"
placeholder={t("fields.phone_secondary.placeholder")}
typePreset="phone"
/>
</FieldGroup>
<Separator className="mt-6" />
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
<Collapsible
className="space-y-8 col-start-1 col-span-full"
onOpenChange={setOpen}
open={open}
>
<CollapsibleTrigger className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
{t("common.actions.more")}{" "}
<ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
</CollapsibleTrigger>
<CollapsibleContent>
<FieldGroup className="grid grid-cols-1 gap-6 lg:grid-cols-4">
<Field className="lg:col-span-2">
<TextField
className="lg:col-span-2"
description={t("fields.website.description")}
label={t("fields.website.label")}
leftIcon={
<GlobeIcon
className="h-[18px] w-[18px] text-muted-foreground"
strokeWidth={1.5}
/>
}
name="website"
placeholder={t("fields.website.placeholder")}
typePreset="text"
/>
</Field>
<Field className="lg:col-span-2">
<TextField
className="lg:col-span-2"
description={t("fields.fax.description")}
label={t("fields.fax.label")}
name="fax"
placeholder={t("fields.fax.placeholder")}
typePreset="phone"
/>
</Field>
</FieldGroup>
</CollapsibleContent>
</Collapsible>
</FieldGroup>
</FieldSet>
);
};

View File

@ -1,30 +0,0 @@
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from "./customer-contact-fields";
type CustomerCreateEditorFormProps = {
formId: string;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
className?: string;
};
export const CustomerCreateEditorForm = ({
formId,
onSubmit,
className,
}: CustomerCreateEditorFormProps) => {
return (
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
<section className={cn("space-y-12 p-6", className)}>
<CustomerBasicInfoFields />
<CustomerAddressFields />
<CustomerContactFields />
<CustomerAdditionalConfigFields />
</section>
</form>
);
};

View File

@ -0,0 +1,56 @@
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
CustomerAdditionalConfigEditor,
CustomerAddressEditor,
CustomerBasicInfoEditor,
CustomerContactEditor,
CustomerFiscalEditor,
} from "../../../update/ui/editor";
import type { UseCustomerCreateFiscalControllerResult } from "../../controllers";
type CustomerCreateEditorProps = {
formId: string;
isSubmitting: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
onReset: () => void;
fiscalCtrl: UseCustomerCreateFiscalControllerResult;
className?: string;
};
export const CustomerCreateEditorForm = ({
formId,
isSubmitting,
onSubmit,
onReset,
fiscalCtrl,
className,
}: CustomerCreateEditorProps) => {
return (
<div className={cn("p-6", className)}>
<form
className="space-y-6 2xl:space-y-12"
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
onReset={onReset}
onSubmit={onSubmit}
>
<CustomerBasicInfoEditor disabled={isSubmitting} />
<CustomerAddressEditor disabled={isSubmitting} />
<CustomerContactEditor disabled={isSubmitting} />
<CustomerFiscalEditor
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
paymentTermOptions={fiscalCtrl.paymentTermOptions}
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
/>
<CustomerAdditionalConfigEditor disabled={isSubmitting} />
</form>
</div>
);
};

View File

@ -1,65 +0,0 @@
import { SpainTaxCatalogProvider } from "@erp/core";
import { MultiSelect } from "@repo/rdx-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useCallback, useMemo } from "react";
import { useTranslation } from "../../../i18n";
interface CustomerTaxesMultiSelectProps {
value?: string[];
onChange: (selectedValues: string[]) => void;
className?: string;
inputId?: string;
[key: string]: unknown;
}
export const CustomerTaxesMultiSelect = ({
value,
onChange,
className,
inputId,
...otherProps
}: CustomerTaxesMultiSelectProps) => {
const { t } = useTranslation();
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
const catalogLookup = useMemo(() => taxCatalog.toOptionLookup(), [taxCatalog]);
const filterSelectedByGroup = useCallback(
(selectedValues: string[]) => {
const groupMap = new Map<string, string>();
for (const code of selectedValues) {
const item = taxCatalog.findByCode(code).getOrUndefined();
const group = item?.group ?? "ungrouped";
groupMap.set(group, code);
}
return Array.from(groupMap.values());
},
[taxCatalog]
);
return (
<div className={cn("w-full", "max-w-md")}>
<MultiSelect
animation={0}
autoFilter
className={cn(
"flex w-full -mt-0.5 px-1 py-0.5 rounded-md border border-input min-h-8 h-auto items-center justify-between hover:bg-inherit [&_svg]:pointer-events-auto",
"hover:border-ring hover:ring-ring/50 hover:ring-2 font-medium bg-muted/50",
className
)}
filterSelected={filterSelectedByGroup}
id={inputId}
maxCount={3}
onValueChange={onChange}
options={catalogLookup}
placeholder={t("components.taxes_multi_select.placeholder")}
value={value ?? []}
variant="secondary"
{...otherProps}
/>
</div>
);
};

View File

@ -1 +1 @@
export * from "./customer-create-editor-form";
export * from "./customer-create-editor";

View File

@ -1,7 +1,6 @@
import { ErrorAlert } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useCustomerCreatePageController } from "../../controllers";
@ -10,11 +9,8 @@ import { CustomerCreateEditorForm } from "../editor";
export const CustomerCreatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { createCtrl, returnTo } = useCustomerCreatePageController();
const { form, formId, onSubmit, resetForm, isCreating, isCreateError, createError } = createCtrl;
const { navigateBack } = useReturnToNavigation({
fallbackPath: returnTo,
});
@ -30,15 +26,15 @@ export const CustomerCreatePage = () => {
hasChanges={createCtrl.form.formState.isDirty}
isSaving={createCtrl.form.formState.isSubmitting}
labels={{
title: "Nuevo cliente",
back: "Volver",
modified: "",
cancel: "Cancelar",
save: "Guardar",
saving: "Guardando...",
moreActions: "Más acciones",
keyboardShortcuts: "Ver atajos de teclado",
delete: "Eliminar",
title: t("create.title"),
back: t("create.header.back"),
modified: t("create.header.modified"),
cancel: t("common.actions.cancel"),
save: t("common.actions.save"),
saving: t("common.actions.saving"),
moreActions: t("common.actions.more"),
keyboardShortcuts: t("create.header.keyboard_shortcuts"),
delete: t("create.header.delete"),
}}
onCancel={handleCancel}
//onDelete={openDeleteDialog}
@ -46,17 +42,21 @@ export const CustomerCreatePage = () => {
//onExportPdf={handleExportPdf}
/>
{isCreateError && (
{createCtrl.isCreateError && (
<ErrorAlert
message={(createError as Error)?.message ?? t("create.errors.message")}
message={(createCtrl.createError as Error)?.message ?? t("create.errors.message")}
title={t("create.errors.title")}
/>
)}
<div className="flex-1 overflow-y-auto">
<CustomerCreateEditorForm
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto my-6"
formId={formId}
onSubmit={onSubmit}
className="max-w-5xl mx-auto mb-12"
fiscalCtrl={createCtrl.fiscalCtrl}
formId={createCtrl.formId}
isSubmitting={createCtrl.isCreating}
onReset={createCtrl.resetForm}
onSubmit={createCtrl.onSubmit}
/>
</div>
</UnsavedChangesProvider>

View File

@ -0,0 +1,91 @@
import type { CreateCustomerParams } from "../../shared";
import type { CustomerCreateData } from "../entities";
/**
* Construye los params de creación de cliente a partir del ID
* generado en cliente y el objeto de creación "CustomerCreateData".
*
* Reglas:
* - el ID se recibe desde fuera para que el controller lo genere antes
* de llamar a la mutation.
* - el shape del payload debe coincidir con el contrato de la API.
* - los selects opcionales sin selección deben omitirse.
* - no debe tener transformaciones globales agresivas.
*/
export const buildCreateCustomerParams = (
id: string,
newData: CustomerCreateData
): CreateCustomerParams => {
const data: CreateCustomerParams["data"] = {
id,
is_company: newData.isCompany,
name: newData.name,
language_code: newData.languageCode,
currency_code: newData.currencyCode,
uses_equivalence_surcharge: newData.usesEquivalenceSurcharge,
uses_retention: newData.usesRetention,
address: {
country: newData.country,
},
};
if (newData.reference !== undefined) {
data.reference = newData.reference;
}
if (newData.tradeName !== undefined) {
data.trade_name = newData.tradeName;
}
if (newData.tin !== undefined) {
data.tin = newData.tin;
}
if (newData.legalRecord !== undefined) {
data.legal_record = newData.legalRecord;
}
// --- legal ---
if (newData.paymentMethodId !== undefined) {
data.payment_method_id = newData.paymentMethodId;
}
if (newData.paymentTermId !== undefined) {
data.payment_term_id = newData.paymentTermId;
}
if (newData.taxRegimeCode !== undefined) {
data.tax_regime_code = newData.taxRegimeCode;
}
// --- address ---
if (newData.street !== undefined) data.address.street = newData.street;
if (newData.street2 !== undefined) data.address.street2 = newData.street2;
if (newData.city !== undefined) data.address.city = newData.city;
if (newData.province !== undefined) data.address.province = newData.province;
if (newData.postalCode !== undefined) data.address.postal_code = newData.postalCode;
// --- contact ---
const contact: Record<string, unknown> = {};
if (newData.primaryEmail !== undefined) contact.email_primary = newData.primaryEmail;
if (newData.secondaryEmail !== undefined) contact.email_secondary = newData.secondaryEmail;
if (newData.primaryPhone !== undefined) contact.phone_primary = newData.primaryPhone;
if (newData.secondaryPhone !== undefined) contact.phone_secondary = newData.secondaryPhone;
if (newData.primaryMobile !== undefined) contact.mobile_primary = newData.primaryMobile;
if (newData.secondaryMobile !== undefined) contact.mobile_secondary = newData.secondaryMobile;
if (newData.fax !== undefined) contact.fax = newData.fax;
if (newData.website !== undefined) contact.website = newData.website;
if (Object.keys(contact).length > 0) {
data.contact = contact;
}
return {
id,
data,
} satisfies CreateCustomerParams;
};

View File

@ -0,0 +1,57 @@
import { toNullableText, toRequiredText } from "@erp/core/client";
import type { CustomerCreateData, CustomerCreateForm } from "../entities";
import { buildCustomerCreateDefault } from "./build-customer-create-defaults";
/**
* Construye un objeto de creación de cliente a partir de los datos
* del formulario.
*
* Reglas:
* - `undefined` = no enviar
*
* @param formData
* @returns
*/
export const buildCustomerCreateData = (formData: CustomerCreateForm): CustomerCreateData => {
const defaults = buildCustomerCreateDefault();
return {
reference: toNullableText(formData.reference) ?? undefined,
isCompany: formData.isCompany,
name: toRequiredText(formData.name),
tradeName: toNullableText(formData.tradeName) ?? undefined,
tin: toNullableText(formData.tin) ?? undefined,
street: toNullableText(formData.street) ?? undefined,
street2: toNullableText(formData.street2) ?? undefined,
city: toNullableText(formData.city) ?? undefined,
province: toNullableText(formData.province) ?? undefined,
postalCode: toNullableText(formData.postalCode) ?? undefined,
country: toRequiredText(formData.country).toUpperCase(),
primaryEmail: toNullableText(formData.primaryEmail) ?? undefined,
secondaryEmail: toNullableText(formData.secondaryEmail) ?? undefined,
primaryPhone: toNullableText(formData.primaryPhone) ?? undefined,
secondaryPhone: toNullableText(formData.secondaryPhone) ?? undefined,
primaryMobile: toNullableText(formData.primaryMobile) ?? undefined,
secondaryMobile: toNullableText(formData.secondaryMobile) ?? undefined,
fax: toNullableText(formData.fax) ?? undefined,
website: toNullableText(formData.website) ?? undefined,
legalRecord: toNullableText(formData.legalRecord) ?? undefined,
paymentMethodId: toNullableText(formData.paymentMethodId) ?? undefined,
paymentTermId: toNullableText(formData.paymentTermId) ?? undefined,
taxRegimeCode: toNullableText(formData.taxRegimeCode) ?? undefined,
usesEquivalenceSurcharge:
formData.usesEquivalenceSurcharge ?? defaults.usesEquivalenceSurcharge,
usesRetention: formData.usesRetention ?? defaults.usesRetention,
languageCode: toRequiredText(formData.languageCode).toLowerCase(),
currencyCode: toRequiredText(formData.currencyCode).toUpperCase(),
};
};

View File

@ -1,5 +1,3 @@
import type { CustomerCreateForm } from "./customer-create-form.entity";
/**
* Valor por defecto para el formulario de creación de cliente.
*
@ -10,7 +8,9 @@ import type { CustomerCreateForm } from "./customer-create-form.entity";
* - orientado a la UI, no a la API ni al dominio, es decir, debe ser un objeto que se pueda usar directamente para inicializar un formulario en la interfaz de usuario
*/
export const defaultCustomerCreateForm: CustomerCreateForm = {
import type { CustomerCreateForm } from "../entities";
export const buildCustomerCreateDefault = (): CustomerCreateForm => ({
reference: "",
isCompany: true,
name: "",
@ -22,7 +22,7 @@ export const defaultCustomerCreateForm: CustomerCreateForm = {
city: "",
province: "",
postalCode: "",
country: "es",
country: "ES",
primaryEmail: "",
secondaryEmail: "",
@ -36,6 +36,13 @@ export const defaultCustomerCreateForm: CustomerCreateForm = {
legalRecord: "",
paymentMethodId: "",
paymentTermId: "",
taxRegimeCode: "",
usesEquivalenceSurcharge: false,
usesRetention: false,
languageCode: "es",
currencyCode: "EUR",
};
});

View File

@ -1,64 +0,0 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { CreateCustomerParams } from "../../shared";
import type { CustomerCreateForm } from "../entities";
/**
* Construye un payload de creación de cliente a partir de los datos
* del formulario y los campos sucios.
*
* Reglas:
* - el payload debe ser un objeto con solo las propiedades que han cambiado (campos sucios).
* - no debe incluir campos que no han cambiado.
* - el shape del payload debe coincidir con el de CustomerCreatePayload,
* es decir, orientado a la API.
* - no debe tener transformaciones ni campos adicionales, solo los que vienen del
* formulario y están sucios.
*
* @param formData - Los datos del formulario de creación de cliente.
* @returns Un objeto que se puede enviar a la API para crear un cliente,
*
*/
export const buildCreateCustomerParams = (formData: CustomerCreateForm): CreateCustomerParams => {
// La API de creación de cliente requiere un ID único para el nuevo cliente
const id = UniqueID.generateNewID().toString();
return {
id,
data: {
id,
reference: formData.reference,
is_company: formData.isCompany,
name: formData.name,
trade_name: formData.tradeName,
tin: formData.tin,
street: formData.street,
street2: formData.street2,
city: formData.city,
province: formData.province,
postal_code: formData.postalCode,
country: formData.country,
email_primary: formData.primaryEmail,
email_secondary: formData.secondaryEmail,
phone_primary: formData.primaryPhone,
phone_secondary: formData.secondaryPhone,
mobile_primary: formData.primaryMobile,
mobile_secondary: formData.secondaryMobile,
fax: formData.fax,
website: formData.website,
legal_record: formData.legalRecord,
uses_equivalence_surcharge: false,
uses_retention: false,
language_code: formData.languageCode,
currency_code: formData.currencyCode,
},
};
};

View File

@ -1 +1,2 @@
export * from "./build-customer-create-params";
export * from "./build-create-customer-params";
export * from "./build-customer-create-defaults";

View File

@ -33,7 +33,17 @@ export const useCustomerCreateMutation = () => {
const result = schema.safeParse(data);
if (!result.success) {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
console.log(
"Error de validación al crear nuevo cliente:",
toValidationErrors(result.error)
);
const errorCollection = new ValidationErrorCollection(
"Validation failed",
toValidationErrors(result.error)
);
console.log("Errores de validación convertidos:", errorCollection);
throw errorCollection;
}
const dto: CreateCustomerResult = await createCustomer(dataSource, params);

View File

@ -27,6 +27,7 @@ export const useCustomerUpdateMutation = () => {
mutationFn: async (params) => {
const { id: customerId, data } = params;
if (!customerId) {
throw new Error("customerId is required");
}

View File

@ -14,8 +14,6 @@ import type { CustomerUpdateForm } from "../entities"; /**
import { buildCustomerUpdateDefault } from "../utils";
export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpdateForm => {
console.log(customer);
return {
reference: customer.reference ?? "",
isCompany: customer.isCompany ?? buildCustomerUpdateDefault().isCompany,

View File

@ -37,6 +37,7 @@ export const CustomerUpdateEditorForm = ({
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
onReset={onReset}
onSubmit={onSubmit}
>
<CustomerBasicInfoEditor disabled={isSubmitting} />

View File

@ -1 +1,6 @@
export * from "./customer-additional-config-fields";
export * from "./customer-address-editor";
export * from "./customer-basic-info-editor";
export * from "./customer-contact-fields";
export * from "./customer-edit-form";
export * from "./customer-fiscal-editor";

View File

@ -13,7 +13,7 @@ export const buildCustomerUpdateDefault = (): CustomerUpdateForm => {
city: "",
province: "",
postalCode: "",
country: "es",
country: "ES",
primaryEmail: "",
secondaryEmail: "",

View File

@ -1,3 +1,3 @@
export * from "./build-customer.update-patch";
export * from "./build-customer-update-default";
export * from "./build-customer-update-patch";
export * from "./build-update-customer-by-id-params";