Creación de cliente
This commit is contained in:
parent
b35b89ee2b
commit
cabea02dcb
@ -9,7 +9,7 @@ export function pickFormDirtyValues<T extends Record<string, any>>(
|
|||||||
const result: Partial<T> = {};
|
const result: Partial<T> = {};
|
||||||
|
|
||||||
for (const key in dirtyFields) {
|
for (const key in dirtyFields) {
|
||||||
if (!Object.prototype.hasOwnProperty.call(dirtyFields, key)) continue;
|
if (!Object.hasOwn(dirtyFields, key)) continue;
|
||||||
|
|
||||||
const isDirty = dirtyFields[key];
|
const isDirty = dirtyFields[key];
|
||||||
const value = values[key];
|
const value = values[key];
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
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
modules/customers/src/web/create/adapters/index.ts
Normal file
1
modules/customers/src/web/create/adapters/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./customer-to-customer-create-form.adapter";
|
||||||
@ -1 +1,2 @@
|
|||||||
|
export * from "./use-customer-create.controller";
|
||||||
export * from "./use-customer-create-page.controller";
|
export * from "./use-customer-create-page.controller";
|
||||||
|
|||||||
@ -1,120 +1,15 @@
|
|||||||
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useHookForm } from "@erp/core/hooks";
|
|
||||||
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
|
|
||||||
import { useId } from "react";
|
|
||||||
import { type FieldErrors, FormProvider } from "react-hook-form";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../i18n";
|
import { useCustomerCreateController } from "./use-customer-create.controller";
|
||||||
import type { Customer } from "../../shared";
|
|
||||||
import { type CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../types";
|
|
||||||
|
|
||||||
export interface UseCustomerCreateControllerOptions {
|
export const useCustomerCreatePageController = () => {
|
||||||
onCreated?(created: Customer): void;
|
const navigate = useNavigate();
|
||||||
successToasts?: boolean; // mostrar o no toast automáticcamente
|
|
||||||
|
|
||||||
onError?(error: Error, patchData: ReturnType<typeof pickFormDirtyValues>): void;
|
const createCtrl = useCustomerCreateController({
|
||||||
errorToasts?: boolean; // mostrar o no toast automáticcamente
|
onCreated: (customer) => navigate(`/customers/${customer.id}`),
|
||||||
}
|
|
||||||
|
|
||||||
export const useCustomerCreateController = (
|
|
||||||
customerId?: string,
|
|
||||||
options?: UseCustomerCreateControllerOptions
|
|
||||||
) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const formId = useId(); // id único por instancia
|
|
||||||
|
|
||||||
// 1) Estado de creación (mutación)
|
|
||||||
const {
|
|
||||||
mutateAsync,
|
|
||||||
isPending,
|
|
||||||
isError: isCreateError,
|
|
||||||
error: createError,
|
|
||||||
} = useCustomerCreateMutation();
|
|
||||||
|
|
||||||
// 2) Form hook
|
|
||||||
const form = useHookForm<CustomerFormData>({
|
|
||||||
resolverSchema: CustomerFormSchema,
|
|
||||||
initialValues: defaultCustomerFormData,
|
|
||||||
disabled: isPending,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Handlers */
|
|
||||||
|
|
||||||
const resetForm = () => form.reset(defaultCustomerFormData);
|
|
||||||
|
|
||||||
// Versión sincronizada
|
|
||||||
const submitHandler = form.handleSubmit(
|
|
||||||
async (formData) => {
|
|
||||||
if (!customerId) {
|
|
||||||
showErrorToast(t("pages.create.error.title"), "Falta el ID del cliente");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dirtyFields } = form.formState;
|
|
||||||
if (!formHasAnyDirty(dirtyFields)) {
|
|
||||||
showWarningToast(t("pages.create.error.no_changes"), "No hay cambios para guardar");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const patchData = pickFormDirtyValues(formData, dirtyFields);
|
|
||||||
const previousData = customerData;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Enviamos cambios al servidor
|
|
||||||
const created = await mutateAsync({ id: customerId, data: patchData });
|
|
||||||
|
|
||||||
// Ha ido bien -> actualizamos form con datos reales
|
|
||||||
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
|
|
||||||
form.reset(created, { keepDirty: false });
|
|
||||||
|
|
||||||
if (options?.successToasts !== false) {
|
|
||||||
showSuccessToast(
|
|
||||||
t("pages.create.success.title", "Cliente modificado"),
|
|
||||||
t("pages.create.success.message", "Se ha modificado correctamente.")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
options?.onCreated?.(created);
|
|
||||||
} catch (error: any) {
|
|
||||||
// Algo ha fallado -> revertimos cambios
|
|
||||||
form.reset(previousData ?? defaultCustomerFormData);
|
|
||||||
if (options?.errorToasts !== false) {
|
|
||||||
showErrorToast(t("pages.create.error.title"), error.message);
|
|
||||||
}
|
|
||||||
options?.onError?.(error, patchData);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(errors: FieldErrors<CustomerFormData>) => {
|
|
||||||
const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined;
|
|
||||||
if (firstKey) document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`)?.focus();
|
|
||||||
|
|
||||||
showWarningToast(
|
|
||||||
t("forms.validation.title", "Revisa los campos"),
|
|
||||||
t("forms.validation.message", "Hay errores de validación en el formulario.")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Evento onSubmit ya preparado para el <form>
|
|
||||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
|
|
||||||
submitHandler(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// form
|
createCtrl,
|
||||||
form,
|
|
||||||
formId,
|
|
||||||
|
|
||||||
// handlers del form
|
|
||||||
onSubmit,
|
|
||||||
resetForm,
|
|
||||||
|
|
||||||
// mutation
|
|
||||||
isPending,
|
|
||||||
isCreateError,
|
|
||||||
createError,
|
|
||||||
|
|
||||||
// Por comodidad
|
|
||||||
FormProvider,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,114 @@
|
|||||||
|
import { useHookForm } from "@erp/core/hooks";
|
||||||
|
import { 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 { Customer } from "../../shared";
|
||||||
|
import { useCustomerCreateMutation } from "../../shared/hooks/use-customer-create-mutation";
|
||||||
|
import {
|
||||||
|
type CustomerCreateForm,
|
||||||
|
CustomerCreateFormSchema,
|
||||||
|
type CustomerCreatePayload,
|
||||||
|
defaultCustomerCreateForm,
|
||||||
|
} from "../entities";
|
||||||
|
import { buildCustomerCreatePayload } from "../utils";
|
||||||
|
|
||||||
|
export interface UseCustomerCreateControllerOptions {
|
||||||
|
onCreated?(created: Customer): void;
|
||||||
|
successToasts?: boolean; // mostrar o no toast automáticcamente
|
||||||
|
|
||||||
|
onError?(error: Error, payloadData: CustomerCreatePayload): void;
|
||||||
|
errorToasts?: boolean; // mostrar o no toast automáticcamente
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCustomerCreateController = (options?: UseCustomerCreateControllerOptions) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const formId = useId(); // id único por instancia
|
||||||
|
|
||||||
|
// 1) Estado de creación (mutación)
|
||||||
|
const {
|
||||||
|
mutateAsync,
|
||||||
|
isPending: isCreating,
|
||||||
|
isError: isCreateError,
|
||||||
|
error: createError,
|
||||||
|
} = useCustomerCreateMutation();
|
||||||
|
|
||||||
|
// 2) Form hook
|
||||||
|
const form = useHookForm<CustomerCreateForm>({
|
||||||
|
resolverSchema: CustomerCreateFormSchema,
|
||||||
|
initialValues: defaultCustomerCreateForm,
|
||||||
|
disabled: isCreating,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Handlers */
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.reset(defaultCustomerCreateForm, { keepDirty: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitHandler = form.handleSubmit(
|
||||||
|
async (formData) => {
|
||||||
|
const payloadData: CustomerCreatePayload = buildCustomerCreatePayload(formData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Enviamos cambios al servidor
|
||||||
|
const created = await mutateAsync(payloadData);
|
||||||
|
|
||||||
|
if (options?.successToasts !== false) {
|
||||||
|
showSuccessToast(
|
||||||
|
t("pages.create.success.title", "Cliente creado"),
|
||||||
|
t("pages.create.success.message", "Se ha creado el cliente correctamente.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
options?.onCreated?.(created);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const normalizedError =
|
||||||
|
error instanceof Error ? error : new Error(t("pages.create.error.unknown"));
|
||||||
|
|
||||||
|
if (options?.errorToasts !== false) {
|
||||||
|
showErrorToast(t("pages.create.error.title"), normalizedError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
options?.onError?.(normalizedError, payloadData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(errors: FieldErrors<CustomerCreateForm>) => {
|
||||||
|
const firstKey = Object.keys(errors)[0] as keyof CustomerCreateForm | undefined;
|
||||||
|
|
||||||
|
if (firstKey) {
|
||||||
|
document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
showWarningToast(
|
||||||
|
t("forms.validation.title", "Revisa los campos"),
|
||||||
|
t("forms.validation.message", "Hay errores de validación en el formulario.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Evento onSubmit ya preparado para el <form>
|
||||||
|
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
|
||||||
|
submitHandler(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// form
|
||||||
|
form,
|
||||||
|
formId,
|
||||||
|
|
||||||
|
// handlers del form
|
||||||
|
onSubmit,
|
||||||
|
resetForm,
|
||||||
|
|
||||||
|
// mutation
|
||||||
|
isCreating,
|
||||||
|
isCreateError,
|
||||||
|
createError,
|
||||||
|
|
||||||
|
// No devolver FormProvider, así el controller es más
|
||||||
|
// flexible y reusable (p.ej. para un modal)
|
||||||
|
// FormProvider,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import type { CustomerCreateForm } from "./customer-create-form.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valor por defecto para el formulario de creación de cliente.
|
||||||
|
*
|
||||||
|
* Reglas:
|
||||||
|
* - debe ser un objeto que cumpla con la interfaz CustomerCreateForm
|
||||||
|
* - debe tener valores por defecto razonables para cada campo, evitando campos vacíos o nulos cuando sea posible
|
||||||
|
* - el shape del objeto debe coincidir exactamente con el de CustomerCreateForm, sin campos adicionales ni transformaciones
|
||||||
|
* - 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 = {
|
||||||
|
reference: "",
|
||||||
|
isCompany: true,
|
||||||
|
name: "",
|
||||||
|
tradeName: "",
|
||||||
|
tin: "",
|
||||||
|
|
||||||
|
defaultTaxes: [],
|
||||||
|
|
||||||
|
street: "",
|
||||||
|
street2: "",
|
||||||
|
city: "",
|
||||||
|
province: "",
|
||||||
|
postalCode: "",
|
||||||
|
country: "es",
|
||||||
|
|
||||||
|
primaryEmail: "",
|
||||||
|
secondaryEmail: "",
|
||||||
|
primaryPhone: "",
|
||||||
|
secondaryPhone: "",
|
||||||
|
primaryMobile: "",
|
||||||
|
secondaryMobile: "",
|
||||||
|
|
||||||
|
fax: "",
|
||||||
|
website: "",
|
||||||
|
|
||||||
|
legalRecord: "",
|
||||||
|
|
||||||
|
languageCode: "es",
|
||||||
|
currencyCode: "EUR",
|
||||||
|
};
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* CustomerCreateForm representa el shape de datos del formulario de creación de cliente.
|
||||||
|
* Es decir, los campos que se muestran en el formulario y que el usuario puede editar.
|
||||||
|
*
|
||||||
|
* Este shape es específico para la UI y no tiene por qué coincidir
|
||||||
|
* con el shape del dominio ni con el de la API.
|
||||||
|
*
|
||||||
|
* Debe cumplir las siguientes reglas:
|
||||||
|
* - nombres en camelCase
|
||||||
|
* - tipos orientados a UI/form
|
||||||
|
* - sin campos de solo lectura que no se editen
|
||||||
|
* - sin shape DTO
|
||||||
|
* - sin detalles impuestos por el widget
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CustomerCreateForm {
|
||||||
|
reference: string;
|
||||||
|
isCompany: boolean;
|
||||||
|
name: string;
|
||||||
|
tradeName: string;
|
||||||
|
tin: string;
|
||||||
|
defaultTaxes: 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;
|
||||||
|
|
||||||
|
languageCode: string;
|
||||||
|
currencyCode: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Este esquema es para validar los datos del formulario de creación de cliente.
|
||||||
|
* No tiene por qué coincidir con el shape de la entidad ni con el de la API.
|
||||||
|
* Solo define los campos que se muestran en el formulario y sus validaciones.
|
||||||
|
*
|
||||||
|
* Reglas:
|
||||||
|
* - no meter transformaciones silenciosas raras en el esquema (ej: .toUpperCase())
|
||||||
|
* - nombres en camelCase
|
||||||
|
* - tipos orientados a UI/form
|
||||||
|
* - sin campos de solo lectura que no se editen
|
||||||
|
* - sin shape DTO
|
||||||
|
* - sin detalles impuestos por el widget
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CustomerCreateFormSchema = z.object({
|
||||||
|
reference: z.string().optional().or(z.literal("")),
|
||||||
|
isCompany: z.boolean(),
|
||||||
|
name: z.string().min(1, "El nombre es obligatorio"),
|
||||||
|
tradeName: z.string().optional().or(z.literal("")),
|
||||||
|
tin: z.string(),
|
||||||
|
|
||||||
|
defaultTaxes: z.array(z.string()),
|
||||||
|
|
||||||
|
street: z.string().optional().or(z.literal("")),
|
||||||
|
street2: z.string().optional().or(z.literal("")),
|
||||||
|
city: z.string().optional().or(z.literal("")),
|
||||||
|
province: z.string().optional().or(z.literal("")),
|
||||||
|
postalCode: z.string().optional().or(z.literal("")),
|
||||||
|
country: z.string().min(1, "El país es obligatorio").optional().or(z.literal("")),
|
||||||
|
|
||||||
|
primaryEmail: z.email("Email inválido").optional().or(z.literal("")),
|
||||||
|
secondaryEmail: z.email("Email inválido").optional().or(z.literal("")),
|
||||||
|
|
||||||
|
primaryPhone: z.string().optional().or(z.literal("")),
|
||||||
|
secondaryPhone: z.string().optional().or(z.literal("")),
|
||||||
|
primaryMobile: z.string().optional().or(z.literal("")),
|
||||||
|
secondaryMobile: z.string().optional().or(z.literal("")),
|
||||||
|
|
||||||
|
fax: z.string().optional().or(z.literal("")),
|
||||||
|
website: z.url("URL inválida").optional().or(z.literal("")),
|
||||||
|
|
||||||
|
legalRecord: z.string().optional().or(z.literal("")),
|
||||||
|
|
||||||
|
languageCode: z.string().min(1, "El idioma es obligatorio"),
|
||||||
|
currencyCode: z.string().min(1, "La moneda es obligatoria"),
|
||||||
|
});
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import type { CustomerCreateForm } from "./customer-create-form.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CustomerCreatePayload es un tipo que representa un objeto con las mismas
|
||||||
|
* propiedades que CustomerCreateForm, pero todas ellas son opcionales.
|
||||||
|
*
|
||||||
|
* Esto es útil para representar los datos que se van a enviar a la API para crear un cliente,
|
||||||
|
* ya que en una creación parcial (POST) no es necesario enviar todos los campos,
|
||||||
|
* sino solo aquellos que se quieren modificar.
|
||||||
|
*
|
||||||
|
* Reglas:
|
||||||
|
* - debe ser un Partial de CustomerCreateForm
|
||||||
|
* - no debe tener campos adicionales ni transformaciones
|
||||||
|
* - debe ser un shape orientado a la API, no a la UI ni al dominio
|
||||||
|
* - sin shape DTO, solo tipos simples y directos
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CustomerCreatePayload = Partial<CustomerCreateForm> & {
|
||||||
|
// Aquí se añaden los campos que la API requiera para la creación de un cliente y que no estén en el formulario.
|
||||||
|
// Por ejemplo:
|
||||||
|
// - id: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
4
modules/customers/src/web/create/entities/index.ts
Normal file
4
modules/customers/src/web/create/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./customer-create-form.entity";
|
||||||
|
export * from "./customer-create-form.schema";
|
||||||
|
export * from "./customer-create-form-default";
|
||||||
|
export * from "./customer-create-payload.entity";
|
||||||
@ -1 +0,0 @@
|
|||||||
export * from "./types";
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
export const CustomerFormSchema = z.object({
|
|
||||||
reference: z.string().optional(),
|
|
||||||
|
|
||||||
is_company: z.string().default("true"),
|
|
||||||
name: z
|
|
||||||
.string({
|
|
||||||
error: "El nombre es obligatorio",
|
|
||||||
})
|
|
||||||
.min(1, "El nombre no puede estar vacío"),
|
|
||||||
trade_name: z.string().optional(),
|
|
||||||
tin: z.string().optional(),
|
|
||||||
default_taxes: z.array(z.string()).default([]),
|
|
||||||
|
|
||||||
street: z.string().optional(),
|
|
||||||
street2: z.string().optional(),
|
|
||||||
city: z.string().optional(),
|
|
||||||
province: z.string().optional(),
|
|
||||||
postal_code: z.string().optional(),
|
|
||||||
country: z
|
|
||||||
.string({
|
|
||||||
error: "El país es obligatorio",
|
|
||||||
})
|
|
||||||
.min(1, "El país no puede estar vacío")
|
|
||||||
.toLowerCase() // asegura minúsculas
|
|
||||||
.default("es"),
|
|
||||||
|
|
||||||
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(),
|
|
||||||
|
|
||||||
fax: z.string().optional(),
|
|
||||||
website: z.string().optional(),
|
|
||||||
|
|
||||||
legal_record: z.string().optional(),
|
|
||||||
|
|
||||||
language_code: z
|
|
||||||
.string({
|
|
||||||
error: "El idioma es obligatorio",
|
|
||||||
})
|
|
||||||
.min(1, "Debe indicar un idioma")
|
|
||||||
.toUpperCase() // asegura mayúsculas
|
|
||||||
.default("es"),
|
|
||||||
|
|
||||||
currency_code: z
|
|
||||||
.string({
|
|
||||||
error: "La moneda es obligatoria",
|
|
||||||
})
|
|
||||||
.min(1, "La moneda no puede estar vacía")
|
|
||||||
.toUpperCase() // asegura mayúsculas
|
|
||||||
.default("EUR"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CustomerFormData = z.infer<typeof CustomerFormSchema>;
|
|
||||||
|
|
||||||
export const defaultCustomerFormData: CustomerFormData = {
|
|
||||||
reference: "",
|
|
||||||
|
|
||||||
is_company: "",
|
|
||||||
name: "",
|
|
||||||
trade_name: "",
|
|
||||||
tin: "",
|
|
||||||
default_taxes: [],
|
|
||||||
|
|
||||||
street: "",
|
|
||||||
street2: "",
|
|
||||||
city: "",
|
|
||||||
province: "",
|
|
||||||
postal_code: "",
|
|
||||||
country: "es",
|
|
||||||
|
|
||||||
email_primary: "",
|
|
||||||
email_secondary: "",
|
|
||||||
phone_primary: "",
|
|
||||||
phone_secondary: "",
|
|
||||||
mobile_primary: "",
|
|
||||||
mobile_secondary: "",
|
|
||||||
|
|
||||||
fax: "",
|
|
||||||
website: "",
|
|
||||||
|
|
||||||
legal_record: "",
|
|
||||||
language_code: "",
|
|
||||||
currency_code: "",
|
|
||||||
};
|
|
||||||
@ -1,110 +1,49 @@
|
|||||||
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
|
import { ErrorAlert, PageHeader } from "@erp/core/components";
|
||||||
import { CreateCommitButtonGroup, UnsavedChangesProvider, useUrlParamId } from "@erp/core/hooks";
|
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
|
||||||
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
|
||||||
|
import { FormProvider } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { useCustomerCreateController } from "../../controllers";
|
import { useCustomerCreatePageController } from "../../controllers";
|
||||||
import { CustomerEditorSkeleton } from "../components";
|
|
||||||
import { CustomerEditForm } from "../editor";
|
import { CustomerCreateForm } from "./editor";
|
||||||
|
|
||||||
export const CustomerCreatePage = () => {
|
export const CustomerCreatePage = () => {
|
||||||
const initialCustomerId = useUrlParamId();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { createCtrl } = useCustomerCreatePageController();
|
||||||
|
|
||||||
const {
|
const { form, formId, onSubmit, resetForm, isCreating, isCreateError, createError } = createCtrl;
|
||||||
form,
|
|
||||||
formId,
|
|
||||||
onSubmit,
|
|
||||||
resetForm,
|
|
||||||
|
|
||||||
customerData,
|
|
||||||
isLoading,
|
|
||||||
isLoadError,
|
|
||||||
loadError,
|
|
||||||
|
|
||||||
isUpdating,
|
|
||||||
isCreateError,
|
|
||||||
createError,
|
|
||||||
|
|
||||||
FormProvider,
|
|
||||||
} = useCustomerCreateController(initialCustomerId, {});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <CustomerEditorSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AppContent>
|
|
||||||
<ErrorAlert
|
|
||||||
message={
|
|
||||||
(loadError as Error)?.message ??
|
|
||||||
t("pages.create.loadErrorMsg", "Inténtalo de nuevo más tarde.")
|
|
||||||
}
|
|
||||||
title={t("pages.create.loadErrorTitle", "No se pudo cargar el cliente")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<BackHistoryButton />
|
|
||||||
</div>
|
|
||||||
</AppContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customerData)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AppContent>
|
|
||||||
<NotFoundCard
|
|
||||||
message={t("pages.create.notFoundMsg", "Revisa el identificador o vuelve al listado.")}
|
|
||||||
title={t("pages.create.notFoundTitle", "Cliente no encontrado")}
|
|
||||||
/>
|
|
||||||
</AppContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnsavedChangesProvider isDirty={false}>
|
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
||||||
<AppHeader>
|
<AppHeader>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
backIcon
|
backIcon
|
||||||
description={t("pages.create.description")}
|
description={t("pages.create.description")}
|
||||||
rightSlot={
|
rightSlot={
|
||||||
<CreateCommitButtonGroup
|
<UpdateCommitButtonGroup
|
||||||
cancel={{
|
cancel={{ formId, to: "/customers/list", disabled: isCreating }}
|
||||||
formId,
|
disabled={isCreating}
|
||||||
to: "/customers/list",
|
isLoading={isCreating}
|
||||||
disabled: isUpdating,
|
|
||||||
}}
|
|
||||||
disabled={isUpdating}
|
|
||||||
isLoading={isUpdating}
|
|
||||||
onReset={resetForm}
|
onReset={resetForm}
|
||||||
submit={{
|
submit={{ formId, disabled: isCreating }}
|
||||||
formId,
|
|
||||||
disabled: isUpdating,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
title={t("pages.create.title")}
|
title={t("pages.create.title")}
|
||||||
/>
|
/>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
|
|
||||||
<AppContent>
|
<AppContent>
|
||||||
{/* Alerta de error de actualización (si ha fallado el último intento) */}
|
|
||||||
{isCreateError && (
|
{isCreateError && (
|
||||||
<ErrorAlert
|
<ErrorAlert
|
||||||
message={
|
message={(createError as Error)?.message ?? t("pages.create.errorMsg")}
|
||||||
(createError as Error)?.message ??
|
title={t("pages.create.errorTitle")}
|
||||||
t("pages.create.errorMsg", "Revisa los datos e inténtalo de nuevo.")
|
|
||||||
}
|
|
||||||
title={t("pages.create.errorTitle", "No se pudo guardar los cambios")}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<CustomerEditForm
|
<CustomerCreateForm
|
||||||
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6 " // para que el botón del header pueda hacer submit
|
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6"
|
||||||
formId={formId}
|
formId={formId}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
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("form_fields.language_code.description")}
|
||||||
|
items={[...LANGUAGE_OPTIONS]}
|
||||||
|
label={t("form_fields.language_code.label")}
|
||||||
|
name="languageCode"
|
||||||
|
placeholder={t("form_fields.language_code.placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field className="lg:col-span-2">
|
||||||
|
<SelectField
|
||||||
|
className="lg:col-span-2"
|
||||||
|
description={t("form_fields.currency_code.description")}
|
||||||
|
items={[...CURRENCY_OPTIONS]}
|
||||||
|
label={t("form_fields.currency_code.label")}
|
||||||
|
name="currencyCode"
|
||||||
|
placeholder={t("form_fields.currency_code.placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
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("form_fields.street.description")}
|
||||||
|
label={t("form_fields.street.label")}
|
||||||
|
name="street"
|
||||||
|
placeholder={t("form_fields.street.placeholder")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-2"
|
||||||
|
description={t("form_fields.street2.description")}
|
||||||
|
label={t("form_fields.street2.label")}
|
||||||
|
name="street2"
|
||||||
|
placeholder={t("form_fields.street2.placeholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-2"
|
||||||
|
description={t("form_fields.city.description")}
|
||||||
|
label={t("form_fields.city.label")}
|
||||||
|
name="city"
|
||||||
|
placeholder={t("form_fields.city.placeholder")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
description={t("form_fields.postal_code.description")}
|
||||||
|
label={t("form_fields.postal_code.label")}
|
||||||
|
name="postalCode"
|
||||||
|
placeholder={t("form_fields.postal_code.placeholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field className="lg:col-span-2 lg:col-start-1">
|
||||||
|
<TextField
|
||||||
|
description={t("form_fields.province.description")}
|
||||||
|
label={t("form_fields.province.label")}
|
||||||
|
name="province"
|
||||||
|
placeholder={t("form_fields.province.placeholder")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field className="lg:col-span-2">
|
||||||
|
<SelectField
|
||||||
|
description={t("form_fields.country.description")}
|
||||||
|
items={[...COUNTRY_OPTIONS]}
|
||||||
|
label={t("form_fields.country.label")}
|
||||||
|
name="country"
|
||||||
|
placeholder={t("form_fields.country.placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
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 { CustomerUpdateForm } from "../../entities";
|
||||||
|
|
||||||
|
import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select";
|
||||||
|
|
||||||
|
interface CustomerBasicInfoFieldsProps extends React.ComponentProps<typeof FieldSet> {}
|
||||||
|
|
||||||
|
export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicInfoFieldsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { control, setFocus } = useFormContext<CustomerUpdateForm>();
|
||||||
|
|
||||||
|
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("form_fields.name.description")}
|
||||||
|
label={t("form_fields.name.label")}
|
||||||
|
name="name"
|
||||||
|
placeholder={t("form_fields.name.placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="isCompany"
|
||||||
|
render={({ field, fieldState }) => {
|
||||||
|
console.log(field.value);
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
className="gap-1 lg:col-span-1 lg:col-start-1"
|
||||||
|
data-invalid={fieldState.invalid}
|
||||||
|
>
|
||||||
|
<FormFieldLabel required>{t("form_fields.customer_type.label")}</FormFieldLabel>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
className="gap-3"
|
||||||
|
disabled={field.disabled}
|
||||||
|
name={field.name}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
console.log("Pongo ", value);
|
||||||
|
field.onChange(value === "true");
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
value={field.value ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<Field data-invalid={fieldState.invalid} orientation="horizontal">
|
||||||
|
<FieldContent>
|
||||||
|
<RadioGroupItem
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
id="customer-type-company"
|
||||||
|
value="true"
|
||||||
|
/>
|
||||||
|
<FieldLabel
|
||||||
|
className="cursor-pointer text-sm font-medium leading-none"
|
||||||
|
htmlFor="customer-type-company"
|
||||||
|
>
|
||||||
|
{t("form_fields.customer_type.company")}
|
||||||
|
</FieldLabel>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field data-invalid={fieldState.invalid} orientation="horizontal">
|
||||||
|
<FieldContent>
|
||||||
|
<RadioGroupItem
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
id="customer-type-individual"
|
||||||
|
value="false"
|
||||||
|
/>
|
||||||
|
<FieldLabel
|
||||||
|
className="cursor-pointer text-sm font-medium leading-none"
|
||||||
|
htmlFor="customer-type-individual"
|
||||||
|
>
|
||||||
|
{t("form_fields.customer_type.individual")}
|
||||||
|
</FieldLabel>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<FieldDescription>{t("form_fields.customer_type.description")}</FieldDescription>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-1"
|
||||||
|
description={t("form_fields.tin.description")}
|
||||||
|
label={t("form_fields.tin.label")}
|
||||||
|
name="tin"
|
||||||
|
placeholder={t("form_fields.tin.placeholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-full"
|
||||||
|
description={t("form_fields.trade_name.description")}
|
||||||
|
label={t("form_fields.trade_name.label")}
|
||||||
|
name="tradeName"
|
||||||
|
placeholder={t("form_fields.trade_name.placeholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-2 lg:col-start-1"
|
||||||
|
description={t("form_fields.reference.description")}
|
||||||
|
label={t("form_fields.reference.label")}
|
||||||
|
name="reference"
|
||||||
|
placeholder={t("form_fields.reference.placeholder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field className="lg:col-span-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="defaultTaxes"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field className="gap-1" data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor="defaultTaxes">
|
||||||
|
{t("form_fields.default_taxes.label")}
|
||||||
|
</FieldLabel>
|
||||||
|
|
||||||
|
<CustomerTaxesMultiSelect
|
||||||
|
description={t("form_fields.default_taxes.description")}
|
||||||
|
label={t("form_fields.default_taxes.label")}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder={t("form_fields.default_taxes.placeholder")}
|
||||||
|
required
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FieldDescription>{t("form_fields.default_taxes.description")}</FieldDescription>
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<TextAreaField
|
||||||
|
className="lg:col-span-full"
|
||||||
|
description={t("form_fields.legal_record.description")}
|
||||||
|
label={t("form_fields.legal_record.label")}
|
||||||
|
name="legalRecord"
|
||||||
|
placeholder={t("form_fields.legal_record.placeholder")}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
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("form_fields.email_primary.description")}
|
||||||
|
label={t("form_fields.email_primary.label")}
|
||||||
|
leftIcon={
|
||||||
|
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||||
|
}
|
||||||
|
name="primaryEmail"
|
||||||
|
placeholder={t("form_fields.email_primary.placeholder")}
|
||||||
|
required
|
||||||
|
typePreset="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-2"
|
||||||
|
description={t("form_fields.mobile_primary.description")}
|
||||||
|
label={t("form_fields.mobile_primary.label")}
|
||||||
|
leftIcon={
|
||||||
|
<SmartphoneIcon
|
||||||
|
className="h-[18px] w-[18px] text-muted-foreground"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
name="primaryMobile"
|
||||||
|
placeholder={t("form_fields.mobile_primary.placeholder")}
|
||||||
|
typePreset="phone"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-2"
|
||||||
|
description={t("form_fields.phone_primary.description")}
|
||||||
|
label={t("form_fields.phone_primary.label")}
|
||||||
|
leftIcon={
|
||||||
|
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||||
|
}
|
||||||
|
name="primaryPhone"
|
||||||
|
placeholder={t("form_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("form_fields.email_secondary.description")}
|
||||||
|
label={t("form_fields.email_secondary.label")}
|
||||||
|
leftIcon={
|
||||||
|
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||||
|
}
|
||||||
|
name="secondaryEmail"
|
||||||
|
placeholder={t("form_fields.email_secondary.placeholder")}
|
||||||
|
typePreset="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-2"
|
||||||
|
description={t("form_fields.mobile_secondary.description")}
|
||||||
|
label={t("form_fields.mobile_secondary.label")}
|
||||||
|
leftIcon={
|
||||||
|
<SmartphoneIcon
|
||||||
|
className="h-[18px] w-[18px] text-muted-foreground"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
name="secondaryMobile"
|
||||||
|
placeholder={t("form_fields.mobile_secondary.placeholder")}
|
||||||
|
typePreset="phone"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-2"
|
||||||
|
description={t("form_fields.phone_secondary.description")}
|
||||||
|
label={t("form_fields.phone_secondary.label")}
|
||||||
|
leftIcon={
|
||||||
|
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||||
|
}
|
||||||
|
name="secondaryPhone"
|
||||||
|
placeholder={t("form_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.more_details")}{" "}
|
||||||
|
<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("form_fields.website.description")}
|
||||||
|
label={t("form_fields.website.label")}
|
||||||
|
leftIcon={
|
||||||
|
<GlobeIcon
|
||||||
|
className="h-[18px] w-[18px] text-muted-foreground"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
name="website"
|
||||||
|
placeholder={t("form_fields.website.placeholder")}
|
||||||
|
typePreset="text"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field className="lg:col-span-2">
|
||||||
|
<TextField
|
||||||
|
className="lg:col-span-2"
|
||||||
|
description={t("form_fields.fax.description")}
|
||||||
|
label={t("form_fields.fax.label")}
|
||||||
|
name="fax"
|
||||||
|
placeholder={t("form_fields.fax.placeholder")}
|
||||||
|
typePreset="phone"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
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 CustomerFormProps = {
|
||||||
|
formId: string;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomerCreateForm = ({ formId, onSubmit, className }: CustomerFormProps) => {
|
||||||
|
return (
|
||||||
|
<form id={formId} noValidate onSubmit={onSubmit}>
|
||||||
|
<section className={cn("space-y-12 p-6", className)}>
|
||||||
|
<CustomerBasicInfoFields />
|
||||||
|
<CustomerAddressFields />
|
||||||
|
<CustomerContactFields />
|
||||||
|
<CustomerAdditionalConfigFields />
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
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.customer_invoice_taxes_multi_select.placeholder")}
|
||||||
|
value={value ?? []}
|
||||||
|
variant="secondary"
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./customer-create-form";
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
|
import type { CustomerCreateForm, CustomerCreatePayload } 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,
|
||||||
|
* con todos los campos necesarios.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const buildCustomerCreatePayload = (formData: CustomerCreateForm): CustomerCreatePayload => {
|
||||||
|
return {
|
||||||
|
...formData,
|
||||||
|
|
||||||
|
// El backend exige que el cliente envíe un id en la creación.
|
||||||
|
id: UniqueID.generateNewID().toString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
1
modules/customers/src/web/create/utils/index.ts
Normal file
1
modules/customers/src/web/create/utils/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./build-customer-create-payload";
|
||||||
@ -9,7 +9,7 @@ const CustomerLayout = lazy(() =>
|
|||||||
const CustomersList = lazy(() => import("./list").then((m) => ({ default: m.ListCustomersPage })));
|
const CustomersList = lazy(() => import("./list").then((m) => ({ default: m.ListCustomersPage })));
|
||||||
const CustomerView = lazy(() => import("./view").then((m) => ({ default: m.CustomerViewPage })));
|
const CustomerView = lazy(() => import("./view").then((m) => ({ default: m.CustomerViewPage })));
|
||||||
|
|
||||||
//const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreatePage })));
|
const CustomerAdd = lazy(() => import("./create").then((m) => ({ default: m.CustomerCreatePage })));
|
||||||
const CustomerUpdate = lazy(() =>
|
const CustomerUpdate = lazy(() =>
|
||||||
import("./update").then((m) => ({ default: m.CustomerUpdatePage }))
|
import("./update").then((m) => ({ default: m.CustomerUpdatePage }))
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
export * from "./use-customer-create-mutation";
|
||||||
|
export * from "./use-customer-delete-mutation";
|
||||||
export * from "./use-customer-get-query";
|
export * from "./use-customer-get-query";
|
||||||
export * from "./use-customer-update-mutation";
|
export * from "./use-customer-update-mutation";
|
||||||
export * from "./use-list-customers-query";
|
export * from "./use-list-customers-query";
|
||||||
|
|||||||
@ -16,28 +16,23 @@ import { toValidationErrors } from "./to-validation-errors";
|
|||||||
|
|
||||||
type CreateCustomerContext = {};
|
type CreateCustomerContext = {};
|
||||||
|
|
||||||
type CreateCustomerPayload = {
|
|
||||||
data: CustomerCreateInput;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCustomerCreateMutation = () => {
|
export const useCustomerCreateMutation = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const schema = CreateCustomerRequestSchema;
|
const schema = CreateCustomerRequestSchema;
|
||||||
|
|
||||||
return useMutation<Customer, DefaultError, CreateCustomerPayload, CreateCustomerContext>({
|
return useMutation<Customer, DefaultError, CustomerCreateInput, CreateCustomerContext>({
|
||||||
mutationKey: CUSTOMER_CREATE_KEY,
|
mutationKey: CUSTOMER_CREATE_KEY,
|
||||||
|
|
||||||
mutationFn: async (payload) => {
|
mutationFn: async (payload) => {
|
||||||
const { data } = payload;
|
|
||||||
const id = UniqueID.generateNewID().toString();
|
const id = UniqueID.generateNewID().toString();
|
||||||
|
|
||||||
const result = schema.safeParse(data);
|
const result = schema.safeParse(payload);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
|
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const dto = await createCustomer(dataSource, id, data);
|
const dto = await createCustomer(dataSource, id, payload);
|
||||||
return GetCustomerByIdAdapter.fromDTO(dto);
|
return GetCustomerByIdAdapter.fromDTO(dto);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
export * from "./use-customer-update-form";
|
|
||||||
export * from "./use-customer-update-patch";
|
|
||||||
@ -17,7 +17,8 @@ import type { CustomerUpdateForm, CustomerUpdatePatch } from "../entities";
|
|||||||
*
|
*
|
||||||
* @param formData
|
* @param formData
|
||||||
* @param dirtyFields
|
* @param dirtyFields
|
||||||
* @returns
|
* @returns Un objeto que se puede enviar a la API para actualizar un cliente,
|
||||||
|
* con solo los campos que han cambiado.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const buildCustomerUpdatePatch = (
|
export const buildCustomerUpdatePatch = (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user