Crear un cliente
This commit is contained in:
parent
d5438e2f4e
commit
329bc6c98c
@ -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";
|
||||
|
||||
1
modules/core/src/web/utils/index.ts
Normal file
1
modules/core/src/web/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./text-normalization-utils";
|
||||
21
modules/core/src/web/utils/text-normalization-utils.ts
Normal file
21
modules/core/src/web/utils/text-normalization-utils.ts
Normal 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;
|
||||
};
|
||||
@ -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>;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
};
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from "./customer-to-customer-create-form.adapter";
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./use-customer-create.controller";
|
||||
export * from "./use-customer-create-fiscal-controller";
|
||||
export * from "./use-customer-create-page.controller";
|
||||
|
||||
@ -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
|
||||
>;
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -39,6 +39,13 @@ export interface CustomerCreateForm {
|
||||
|
||||
legalRecord: string;
|
||||
|
||||
paymentMethodId: string;
|
||||
paymentTermId: string;
|
||||
taxRegimeCode: string;
|
||||
|
||||
usesEquivalenceSurcharge: boolean;
|
||||
usesRetention: boolean;
|
||||
|
||||
languageCode: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}
|
||||
);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./customer-editor-skeleton";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1 +1 @@
|
||||
export * from "./customer-create-editor-form";
|
||||
export * from "./customer-create-editor";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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(),
|
||||
};
|
||||
};
|
||||
@ -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",
|
||||
};
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -1 +1,2 @@
|
||||
export * from "./build-customer-create-params";
|
||||
export * from "./build-create-customer-params";
|
||||
export * from "./build-customer-create-defaults";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -27,6 +27,7 @@ export const useCustomerUpdateMutation = () => {
|
||||
|
||||
mutationFn: async (params) => {
|
||||
const { id: customerId, data } = params;
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error("customerId is required");
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -37,6 +37,7 @@ export const CustomerUpdateEditorForm = ({
|
||||
id={formId}
|
||||
noValidate
|
||||
onKeyDown={preventEnterKeySubmitForm}
|
||||
onReset={onReset}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<CustomerBasicInfoEditor disabled={isSubmitting} />
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -13,7 +13,7 @@ export const buildCustomerUpdateDefault = (): CustomerUpdateForm => {
|
||||
city: "",
|
||||
province: "",
|
||||
postalCode: "",
|
||||
country: "es",
|
||||
country: "ES",
|
||||
|
||||
primaryEmail: "",
|
||||
secondaryEmail: "",
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user