Repaso a customers
This commit is contained in:
parent
6e97e91173
commit
7c2a0b8a51
@ -3,7 +3,7 @@ import { UnsavedChangesProvider, UpdateCommitButtonGroup, useUrlParamId } from "
|
||||
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { useCustomerUpdateController } from "../../../update/controllers/use-customer-update-page.controller";
|
||||
import { useCustomerUpdatePageController } from "../../../update/controllers/use-customer-update.controller";
|
||||
import {
|
||||
CustomerEditForm,
|
||||
CustomerEditorSkeleton,
|
||||
@ -31,7 +31,7 @@ export const CustomerUpdatePage = () => {
|
||||
updateError,
|
||||
|
||||
FormProvider,
|
||||
} = useCustomerUpdateController(customerId, {});
|
||||
} = useCustomerUpdatePageController(customerId, {});
|
||||
|
||||
if (isLoading) {
|
||||
return <CustomerEditorSkeleton />;
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import type { Customer } from "../../shared";
|
||||
import type { CustomerUpdateForm } from "../entities";
|
||||
|
||||
/**
|
||||
* Mapea un cliente a un formulario de actualizació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 actualización.
|
||||
*
|
||||
* @param customer
|
||||
* @returns
|
||||
*/
|
||||
|
||||
export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpdateForm => {
|
||||
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/update/adapters/index.ts
Normal file
1
modules/customers/src/web/update/adapters/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer-to-customer-update-form.adapter";
|
||||
@ -1 +1,2 @@
|
||||
export * from "./use-customer-update.controller";
|
||||
export * from "./use-customer-update-page.controller";
|
||||
|
||||
@ -1,145 +1,18 @@
|
||||
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
|
||||
import { useHookForm } from "@erp/core/hooks";
|
||||
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
|
||||
import { useEffect, useId, useMemo } from "react";
|
||||
import { type FieldErrors, FormProvider } from "react-hook-form";
|
||||
import { useUrlParamId } from "@erp/core/hooks";
|
||||
import { useCustomerUpdateController } from "./use-customer-update.controller";
|
||||
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { type Customer, useCustomerGetQuery, useCustomerUpdateMutation } from "../../shared";
|
||||
import { type CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../types";
|
||||
/**
|
||||
* Hook para gestionar el controlador de la página de actualización de cliente.
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
|
||||
export interface UseCustomerUpdateControllerOptions {
|
||||
onUpdated?(updated: Customer): void;
|
||||
successToasts?: boolean; // mostrar o no toast automáticcamente
|
||||
export const useCustomerUpdatePageController = () => {
|
||||
const customerId = useUrlParamId();
|
||||
|
||||
onError?(error: Error, patchData: ReturnType<typeof pickFormDirtyValues>): void;
|
||||
errorToasts?: boolean; // mostrar o no toast automáticcamente
|
||||
}
|
||||
|
||||
export const useCustomerUpdateController = (
|
||||
customerId?: string,
|
||||
options?: UseCustomerUpdateControllerOptions
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const formId = useId(); // id único por instancia
|
||||
|
||||
// 1) Estado de carga del cliente (query)
|
||||
const {
|
||||
data: customerData,
|
||||
isLoading,
|
||||
isError: isLoadError,
|
||||
error: loadError,
|
||||
} = useCustomerGetQuery(customerId, { enabled: Boolean(customerId) });
|
||||
|
||||
// 2) Estado de creación (mutación)
|
||||
const {
|
||||
mutateAsync,
|
||||
isPending: isUpdating,
|
||||
isError: isUpdateError,
|
||||
error: updateError,
|
||||
} = useCustomerUpdateMutation();
|
||||
|
||||
const initialValues = useMemo(() => customerData ?? defaultCustomerFormData, [customerData]);
|
||||
|
||||
// 3) Form hook
|
||||
const form = useHookForm<CustomerFormData>({
|
||||
resolverSchema: CustomerFormSchema,
|
||||
initialValues,
|
||||
disabled: isLoading || isUpdating,
|
||||
});
|
||||
|
||||
/** Reiniciar el form al recibir datos */
|
||||
useEffect(() => {
|
||||
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
|
||||
if (customerData) {
|
||||
console.log("Reseteando form con datos del cliente:", customerData);
|
||||
form.reset(customerData, { keepDirty: false });
|
||||
}
|
||||
}, [customerData, form]);
|
||||
|
||||
/** Handlers */
|
||||
|
||||
const resetForm = () => form.reset(customerData ?? defaultCustomerFormData);
|
||||
|
||||
// Versión sincronizada
|
||||
const submitHandler = form.handleSubmit(
|
||||
async (formData) => {
|
||||
if (!customerId) {
|
||||
showErrorToast(t("pages.update.error.title"), "Falta el ID del cliente");
|
||||
return;
|
||||
}
|
||||
|
||||
const { dirtyFields } = form.formState;
|
||||
if (!formHasAnyDirty(dirtyFields)) {
|
||||
showWarningToast(t("pages.update.error.no_changes"), "No hay cambios para guardar");
|
||||
return;
|
||||
}
|
||||
|
||||
const patchData = pickFormDirtyValues(formData, dirtyFields);
|
||||
const previousData = customerData;
|
||||
|
||||
try {
|
||||
// Enviamos cambios al servidor
|
||||
const updated = 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(updated, { keepDirty: false });
|
||||
|
||||
if (options?.successToasts !== false) {
|
||||
showSuccessToast(
|
||||
t("pages.update.success.title", "Cliente modificado"),
|
||||
t("pages.update.success.message", "Se ha modificado correctamente.")
|
||||
);
|
||||
}
|
||||
options?.onUpdated?.(updated);
|
||||
} catch (error: any) {
|
||||
// Algo ha fallado -> revertimos cambios
|
||||
form.reset(previousData ?? defaultCustomerFormData);
|
||||
if (options?.errorToasts !== false) {
|
||||
showErrorToast(t("pages.update.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);
|
||||
};
|
||||
const updateCtrl = useCustomerUpdateController(customerId);
|
||||
|
||||
return {
|
||||
// form
|
||||
form,
|
||||
formId,
|
||||
|
||||
// handlers del form
|
||||
onSubmit,
|
||||
resetForm,
|
||||
|
||||
// carga de datos
|
||||
customerData,
|
||||
isLoading,
|
||||
isLoadError,
|
||||
loadError,
|
||||
|
||||
// mutation
|
||||
isUpdating,
|
||||
isUpdateError,
|
||||
updateError,
|
||||
|
||||
// Por comodidad
|
||||
FormProvider,
|
||||
updateCtrl,
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,177 @@
|
||||
import { formHasAnyDirty } from "@erp/core/client";
|
||||
import { useHookForm } from "@erp/core/hooks";
|
||||
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
|
||||
import { useEffect, useId, useMemo } from "react";
|
||||
import type { FieldErrors } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { type Customer, useCustomerGetQuery, useCustomerUpdateMutation } from "../../shared";
|
||||
import { mapCustomerToCustomerUpdateForm } from "../adapters";
|
||||
import {
|
||||
type CustomerUpdateForm,
|
||||
CustomerUpdateFormSchema,
|
||||
type CustomerUpdatePatch,
|
||||
defaultCustomerUpdateForm,
|
||||
} from "../entities";
|
||||
import { buildCustomerUpdatePatch } from "../utils";
|
||||
|
||||
export interface UseCustomerUpdateControllerOptions {
|
||||
onUpdated?(updated: Customer): void;
|
||||
successToasts?: boolean; // mostrar o no toast automáticamente
|
||||
|
||||
onError?(error: Error, patchData: CustomerUpdatePatch): void;
|
||||
errorToasts?: boolean; // mostrar o no toast automáticamente
|
||||
}
|
||||
|
||||
export const useCustomerUpdateController = (
|
||||
customerId?: string,
|
||||
options?: UseCustomerUpdateControllerOptions
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const formId = useId(); // id único por instancia
|
||||
|
||||
// 1) Estado de carga del cliente (query)
|
||||
const {
|
||||
data: customerData,
|
||||
isLoading,
|
||||
isError: isLoadError,
|
||||
error: loadError,
|
||||
} = useCustomerGetQuery(customerId, { enabled: Boolean(customerId) });
|
||||
|
||||
// 2) Estado de creación (mutación)
|
||||
const {
|
||||
mutateAsync,
|
||||
isPending: isUpdating,
|
||||
isError: isUpdateError,
|
||||
error: updateError,
|
||||
} = useCustomerUpdateMutation();
|
||||
|
||||
const initialValues = useMemo<CustomerUpdateForm>(() => {
|
||||
if (!customerData) return defaultCustomerUpdateForm;
|
||||
|
||||
return mapCustomerToCustomerUpdateForm(customerData);
|
||||
}, [customerData]);
|
||||
|
||||
// 3) Form hook
|
||||
const form = useHookForm<CustomerUpdateForm>({
|
||||
resolverSchema: CustomerUpdateFormSchema,
|
||||
initialValues,
|
||||
disabled: isLoading || isUpdating,
|
||||
});
|
||||
|
||||
/** Reiniciar el form al recibir datos */
|
||||
useEffect(() => {
|
||||
if (!customerData) return;
|
||||
|
||||
console.log("Reseteando form con datos del cliente:", customerData);
|
||||
form.reset(mapCustomerToCustomerUpdateForm(customerData), {
|
||||
keepDirty: false, // <-- importante: no marca el form como "dirty" al cargar los datos reales
|
||||
});
|
||||
}, [customerData, form]);
|
||||
|
||||
/** Handlers */
|
||||
|
||||
const resetForm = () => {
|
||||
const initialData = customerData
|
||||
? mapCustomerToCustomerUpdateForm(customerData)
|
||||
: defaultCustomerUpdateForm;
|
||||
|
||||
form.reset(initialData, { keepDirty: false });
|
||||
};
|
||||
|
||||
const submitHandler = form.handleSubmit(
|
||||
async (formData) => {
|
||||
if (!customerId) {
|
||||
showErrorToast(t("pages.update.error.title"), "Falta el ID del cliente");
|
||||
return;
|
||||
}
|
||||
|
||||
const { dirtyFields } = form.formState;
|
||||
|
||||
if (!formHasAnyDirty(dirtyFields)) {
|
||||
showWarningToast(t("pages.update.error.no_changes"), "No hay cambios para guardar");
|
||||
return;
|
||||
}
|
||||
|
||||
const patchData: CustomerUpdatePatch = buildCustomerUpdatePatch(formData, dirtyFields);
|
||||
const previousData = customerData;
|
||||
|
||||
try {
|
||||
// Enviamos cambios al servidor
|
||||
const updated = 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(mapCustomerToCustomerUpdateForm(updated), {
|
||||
keepDirty: false,
|
||||
});
|
||||
|
||||
if (options?.successToasts !== false) {
|
||||
showSuccessToast(
|
||||
t("pages.update.success.title", "Cliente modificado"),
|
||||
t("pages.update.success.message", "Se ha modificado correctamente.")
|
||||
);
|
||||
}
|
||||
|
||||
options?.onUpdated?.(updated);
|
||||
} catch (error: unknown) {
|
||||
const normalizedError =
|
||||
error instanceof Error ? error : new Error(t("pages.update.error.unknown"));
|
||||
|
||||
form.reset(
|
||||
previousData ? mapCustomerToCustomerUpdateForm(previousData) : defaultCustomerUpdateForm,
|
||||
{ keepDirty: false }
|
||||
);
|
||||
|
||||
if (options?.errorToasts !== false) {
|
||||
showErrorToast(t("pages.update.error.title"), normalizedError.message);
|
||||
}
|
||||
|
||||
options?.onError?.(normalizedError, patchData);
|
||||
}
|
||||
},
|
||||
(errors: FieldErrors<CustomerUpdateForm>) => {
|
||||
const firstKey = Object.keys(errors)[0] as keyof CustomerUpdateForm | 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,
|
||||
|
||||
// carga de datos
|
||||
customerData,
|
||||
isLoading,
|
||||
isLoadError,
|
||||
loadError,
|
||||
|
||||
// mutation
|
||||
isUpdating,
|
||||
isUpdateError,
|
||||
updateError,
|
||||
|
||||
// No devolver FormProvider, así el controller es más
|
||||
// flexible y reusable (p.ej. para un modal)
|
||||
// FormProvider,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import type { CustomerUpdateForm } from "./customer-update-form.entity";
|
||||
|
||||
export const defaultCustomerUpdateForm: CustomerUpdateForm = {
|
||||
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,46 @@
|
||||
/**
|
||||
* CustomerUpdateForm representa el shape de datos del formulario de actualizació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 CustomerUpdateForm {
|
||||
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,47 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Este esquema es para validar los datos del formulario de actualizació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 CustomerUpdateFormSchema = z.object({
|
||||
reference: z.string(),
|
||||
isCompany: z.boolean(),
|
||||
name: z.string().min(1, "El nombre es obligatorio"),
|
||||
tradeName: z.string(),
|
||||
tin: z.string(),
|
||||
|
||||
defaultTaxes: z.array(z.string()),
|
||||
|
||||
street: z.string(),
|
||||
street2: z.string(),
|
||||
city: z.string(),
|
||||
province: z.string(),
|
||||
postalCode: z.string(),
|
||||
country: z.string().min(1, "El país es obligatorio"),
|
||||
|
||||
primaryEmail: z.string(),
|
||||
secondaryEmail: z.string(),
|
||||
primaryPhone: z.string(),
|
||||
secondaryPhone: z.string(),
|
||||
primaryMobile: z.string(),
|
||||
secondaryMobile: z.string(),
|
||||
|
||||
fax: z.string(),
|
||||
website: z.string(),
|
||||
|
||||
legalRecord: z.string(),
|
||||
|
||||
languageCode: z.string().min(1, "El idioma es obligatorio"),
|
||||
currencyCode: z.string().min(1, "La moneda es obligatoria"),
|
||||
});
|
||||
@ -0,0 +1,18 @@
|
||||
import type { CustomerUpdateForm } from "./customer-update-form.entity";
|
||||
|
||||
/**
|
||||
* CustomerUpdatePatch es un tipo que representa un objeto con las mismas
|
||||
* propiedades que CustomerUpdateForm, pero todas ellas son opcionales.
|
||||
*
|
||||
* Esto es útil para representar los datos que se van a enviar a la API para actualizar un cliente,
|
||||
* ya que en una actualización parcial (PATCH) no es necesario enviar todos los campos,
|
||||
* sino solo aquellos que se quieren modificar.
|
||||
*
|
||||
* Reglas:
|
||||
* - debe ser un Partial de CustomerUpdateForm
|
||||
* - 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 CustomerUpdatePatch = Partial<CustomerUpdateForm>;
|
||||
4
modules/customers/src/web/update/entities/index.ts
Normal file
4
modules/customers/src/web/update/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./customer-update-form.entity";
|
||||
export * from "./customer-update-form.schema";
|
||||
export * from "./customer-update-form-defaults";
|
||||
export * from "./customer-update-patch.entity";
|
||||
2
modules/customers/src/web/update/hooks/index.ts
Normal file
2
modules/customers/src/web/update/hooks/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./use-customer-update-form";
|
||||
export * from "./use-customer-update-patch";
|
||||
@ -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: "",
|
||||
};
|
||||
@ -6,11 +6,9 @@ import {
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../../shared";
|
||||
import type { CustomerFormData } from "../../types";
|
||||
|
||||
interface CustomerAdditionalConfigFieldsProps {
|
||||
className?: string;
|
||||
@ -21,19 +19,19 @@ export const CustomerAdditionalConfigFields = ({
|
||||
...props
|
||||
}: CustomerAdditionalConfigFieldsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<CustomerFormData>();
|
||||
|
||||
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="language_code"
|
||||
name="languageCode"
|
||||
placeholder={t("form_fields.language_code.placeholder")}
|
||||
required
|
||||
/>
|
||||
@ -44,7 +42,7 @@ export const CustomerAdditionalConfigFields = ({
|
||||
description={t("form_fields.currency_code.description")}
|
||||
items={[...CURRENCY_OPTIONS]}
|
||||
label={t("form_fields.currency_code.label")}
|
||||
name="currency_code"
|
||||
name="currencyCode"
|
||||
placeholder={t("form_fields.currency_code.placeholder")}
|
||||
required
|
||||
/>
|
||||
|
||||
@ -6,11 +6,9 @@ import {
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { COUNTRY_OPTIONS } from "../../../shared";
|
||||
import type { CustomerFormData } from "../../types";
|
||||
|
||||
interface CustomerAddressFieldsProps {
|
||||
className?: string;
|
||||
@ -18,12 +16,12 @@ interface CustomerAddressFieldsProps {
|
||||
|
||||
export const CustomerAddressFields = ({ className, ...props }: CustomerAddressFieldsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<CustomerFormData>();
|
||||
|
||||
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"
|
||||
@ -50,7 +48,7 @@ export const CustomerAddressFields = ({ className, ...props }: CustomerAddressFi
|
||||
<TextField
|
||||
description={t("form_fields.postal_code.description")}
|
||||
label={t("form_fields.postal_code.label")}
|
||||
name="postal_code"
|
||||
name="postalCode"
|
||||
placeholder={t("form_fields.postal_code.placeholder")}
|
||||
/>
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import { useEffect } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import type { CustomerFormData } from "../../types";
|
||||
import type { CustomerUpdateForm } from "../../entities";
|
||||
|
||||
import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select";
|
||||
|
||||
@ -26,9 +26,8 @@ export const CustomerBasicInfoFields = ({
|
||||
...props
|
||||
}: CustomerBasicInfoFieldsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { control, setFocus } = useFormContext<CustomerFormData>();
|
||||
const { control, setFocus } = useFormContext<CustomerUpdateForm>();
|
||||
|
||||
// Enfoca el primer campo recibido
|
||||
useEffect(() => {
|
||||
setFocus("name");
|
||||
}, [setFocus]);
|
||||
@ -37,6 +36,7 @@ export const CustomerBasicInfoFields = ({
|
||||
<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"
|
||||
@ -47,22 +47,34 @@ export const CustomerBasicInfoFields = ({
|
||||
required
|
||||
/>
|
||||
|
||||
<RadioGroupField
|
||||
className="lg:col-span-1 lg:col-start-1"
|
||||
description={t("form_fields.customer_type.description")}
|
||||
items={[
|
||||
{
|
||||
value: "true",
|
||||
label: t("form_fields.customer_type.company"),
|
||||
},
|
||||
{
|
||||
value: "false",
|
||||
label: t("form_fields.customer_type.individual"),
|
||||
},
|
||||
]}
|
||||
label={t("form_fields.customer_type.label")}
|
||||
name="is_company"
|
||||
/>
|
||||
<Field className="lg:col-span-1 lg:col-start-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="isCompany"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field className="gap-1" data-invalid={fieldState.invalid}>
|
||||
<RadioGroupField
|
||||
description={t("form_fields.customer_type.description")}
|
||||
items={[
|
||||
{
|
||||
value: "true",
|
||||
label: t("form_fields.customer_type.company"),
|
||||
},
|
||||
{
|
||||
value: "false",
|
||||
label: t("form_fields.customer_type.individual"),
|
||||
},
|
||||
]}
|
||||
label={t("form_fields.customer_type.label")}
|
||||
name={field.name}
|
||||
onValueChange={(value) => field.onChange(value === "true")}
|
||||
value={String(field.value)}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<TextField
|
||||
className="lg:col-span-1"
|
||||
@ -77,7 +89,7 @@ export const CustomerBasicInfoFields = ({
|
||||
className="lg:col-span-full"
|
||||
description={t("form_fields.trade_name.description")}
|
||||
label={t("form_fields.trade_name.label")}
|
||||
name="trade_name"
|
||||
name="tradeName"
|
||||
placeholder={t("form_fields.trade_name.placeholder")}
|
||||
/>
|
||||
|
||||
@ -92,10 +104,10 @@ export const CustomerBasicInfoFields = ({
|
||||
<Field className="lg:col-span-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="default_taxes"
|
||||
name="defaultTaxes"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field className="gap-1" data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="default_taxes">
|
||||
<FieldLabel htmlFor="defaultTaxes">
|
||||
{t("form_fields.default_taxes.label")}
|
||||
</FieldLabel>
|
||||
|
||||
@ -107,17 +119,19 @@ export const CustomerBasicInfoFields = ({
|
||||
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="legal_record"
|
||||
name="legalRecord"
|
||||
placeholder={t("form_fields.legal_record.placeholder")}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
@ -12,7 +12,6 @@ import {
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { AtSignIcon, ChevronDown, GlobeIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
|
||||
@ -23,7 +22,6 @@ interface CustomerContactFieldsProps {
|
||||
export const CustomerContactFields = ({ className, ...props }: CustomerContactFieldsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(true);
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<FieldSet className={className} {...props}>
|
||||
@ -37,7 +35,7 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
leftIcon={
|
||||
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||
}
|
||||
name="email_primary"
|
||||
name="primaryEmail"
|
||||
placeholder={t("form_fields.email_primary.placeholder")}
|
||||
required
|
||||
typePreset="email"
|
||||
@ -53,7 +51,7 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
name="mobile_primary"
|
||||
name="primaryMobile"
|
||||
placeholder={t("form_fields.mobile_primary.placeholder")}
|
||||
typePreset="phone"
|
||||
/>
|
||||
@ -65,7 +63,7 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
leftIcon={
|
||||
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||
}
|
||||
name="phone_primary"
|
||||
name="primaryPhone"
|
||||
placeholder={t("form_fields.phone_primary.placeholder")}
|
||||
typePreset="phone"
|
||||
/>
|
||||
@ -79,7 +77,7 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
leftIcon={
|
||||
<AtSignIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||
}
|
||||
name="email_secondary"
|
||||
name="secondaryEmail"
|
||||
placeholder={t("form_fields.email_secondary.placeholder")}
|
||||
typePreset="email"
|
||||
/>
|
||||
@ -94,7 +92,7 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
name="mobile_secondary"
|
||||
name="secondaryMobile"
|
||||
placeholder={t("form_fields.mobile_secondary.placeholder")}
|
||||
typePreset="phone"
|
||||
/>
|
||||
@ -105,7 +103,7 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
|
||||
leftIcon={
|
||||
<PhoneIcon className="h-[18px] w-[18px] text-muted-foreground" strokeWidth={1.5} />
|
||||
}
|
||||
name="phone_secondary"
|
||||
name="secondaryPhone"
|
||||
placeholder={t("form_fields.phone_secondary.placeholder")}
|
||||
typePreset="phone"
|
||||
/>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { FormDebug } from "@erp/core/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
|
||||
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
|
||||
@ -16,8 +15,7 @@ type CustomerFormProps = {
|
||||
export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => {
|
||||
return (
|
||||
<form id={formId} noValidate onSubmit={onSubmit}>
|
||||
<FormDebug enabled />
|
||||
<section className={cn("space-y-12 p-6 bg-red-800", className)}>
|
||||
<section className={cn("space-y-12 p-6", className)}>
|
||||
<CustomerBasicInfoFields focusRef={focusRef} />
|
||||
<CustomerAddressFields />
|
||||
<CustomerContactFields />
|
||||
|
||||
@ -46,7 +46,7 @@ export const CustomerTaxesMultiSelect = (props: CustomerTaxesMultiSelect) => {
|
||||
autoFilter={true}
|
||||
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-[2px] font-medium bg-muted/50",
|
||||
"hover:border-ring hover:ring-ring/50 hover:ring-2 font-medium bg-muted/50",
|
||||
className
|
||||
)}
|
||||
defaultValue={value}
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, UpdateCommitButtonGroup, useUrlParamId } from "@erp/core/hooks";
|
||||
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
|
||||
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { useCustomerUpdateController } from "../../controllers";
|
||||
import { useCustomerUpdatePageController } from "../../controllers";
|
||||
import { CustomerEditorSkeleton } from "../components";
|
||||
import { CustomerEditForm } from "../editor";
|
||||
|
||||
export const CustomerUpdatePage = () => {
|
||||
const initialCustomerId = useUrlParamId();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { updateCtrl } = useCustomerUpdatePageController();
|
||||
|
||||
const {
|
||||
form,
|
||||
formId,
|
||||
@ -25,9 +27,9 @@ export const CustomerUpdatePage = () => {
|
||||
isUpdating,
|
||||
isUpdateError,
|
||||
updateError,
|
||||
} = updateCtrl;
|
||||
|
||||
FormProvider,
|
||||
} = useCustomerUpdateController(initialCustomerId, {});
|
||||
const isDirty = form.formState.isDirty;
|
||||
|
||||
if (isLoading) {
|
||||
return <CustomerEditorSkeleton />;
|
||||
@ -66,7 +68,7 @@ export const CustomerUpdatePage = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<UnsavedChangesProvider isDirty={false}>
|
||||
<UnsavedChangesProvider isDirty={isDirty}>
|
||||
<AppHeader>
|
||||
<PageHeader
|
||||
backIcon
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { pickFormDirtyValues } from "@erp/core/client";
|
||||
import type { FieldNamesMarkedBoolean } from "react-hook-form";
|
||||
|
||||
import type { CustomerUpdateForm, CustomerUpdatePatch } from "../entities";
|
||||
|
||||
/**
|
||||
* Construye un parche de actualización de cliente a partir de los datos
|
||||
* del formulario y los campos sucios.
|
||||
*
|
||||
* Reglas:
|
||||
* - el parche debe ser un objeto con solo las propiedades que han cambiado (campos sucios).
|
||||
* - no debe incluir campos que no han cambiado.
|
||||
* - el shape del parche debe coincidir con el de CustomerUpdatePatch,
|
||||
* 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
|
||||
* @param dirtyFields
|
||||
* @returns
|
||||
*/
|
||||
|
||||
export const buildCustomerUpdatePatch = (
|
||||
formData: CustomerUpdateForm,
|
||||
dirtyFields: FieldNamesMarkedBoolean<CustomerUpdateForm>
|
||||
): CustomerUpdatePatch => {
|
||||
return pickFormDirtyValues(formData, dirtyFields) as CustomerUpdatePatch;
|
||||
};
|
||||
1
modules/customers/src/web/update/utils/index.ts
Normal file
1
modules/customers/src/web/update/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./build-customer-update-patch";
|
||||
Loading…
Reference in New Issue
Block a user