diff --git a/modules/core/src/api/application/mappers/patch-collector.ts b/modules/core/src/api/application/mappers/patch-collector.ts new file mode 100644 index 00000000..5cc98575 --- /dev/null +++ b/modules/core/src/api/application/mappers/patch-collector.ts @@ -0,0 +1,151 @@ +import { DomainError, ValidationErrorCollection, type ValidationErrorDetail } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +/** + * PatchCollector + * + * Utilidad de apoyo para la construcción de objetos de tipo PATCH en mappers de Application. + * + * Responsabilidad: + * - Acumular propiedades parciales válidas (`patch`) + * - Centralizar la validación de entrada (DTO → Value Objects) + * - Registrar errores de validación de forma homogénea + * - Construir un `Result` final consistente (éxito o `ValidationErrorCollection`) + * + * Contexto de uso: + * - Mappers de tipo `RequestDTO -> *PatchProps` + * - Casos de uso de actualización (semántica PATCH) + * + * Semántica aplicada (PATCH): + * - `undefined` → campo omitido → no se modifica + * - `null` → eliminación explícita (solo en campos nullable) + * - valor definido → validación + transformación a VO → asignación + * + * Capacidades: + * - Helpers para distintos tipos de campo: + * - `setBoolean` → campos primitivos no anulables + * - `setRequiredVo` → campos obligatorios cuando se envían + * - `setNullableVo` → campos opcionales/anulables (usa Maybe/null) + * - `setNested` → composición de sub-objetos (address, contact, etc.) + * + * Reglas de diseño: + * - No contiene lógica de negocio + * - No accede a repositorios ni infraestructura + * - No construye agregados + * - No conoce DTOs concretos ni dominio específico + * - Es determinista y libre de efectos secundarios externos + * + * Manejo de errores: + * - Los errores se acumulan como `ValidationErrorDetail[]` + * - `build()` devuelve: + * - `Result.ok(patch)` si no hay errores + * - `Result.fail(ValidationErrorCollection)` si existen errores + * + * Objetivo: + * Reducir complejidad accidental en mappers, evitar duplicación de lógica + * y garantizar consistencia en validación y reporting de errores. + */ +export class PatchCollector { + private readonly errors: ValidationErrorDetail[] = []; + + public addError(path: string, message: string): void { + this.errors.push({ path, message }); + } + + public hasErrors(): boolean { + return this.errors.length > 0; + } + + public build(value: T, message: string): Result { + if (this.errors.length > 0) { + return Result.fail(new ValidationErrorCollection(message, this.errors)); + } + + return Result.ok(value); + } + + public failUnexpected(message: string, cause: unknown): Result { + return Result.fail(new DomainError(message, { cause })); + } + + public setBoolean( + path: string, + input: boolean | undefined, + setter: (value: boolean) => void + ): void { + if (input === undefined) { + return; + } + + setter(input); + } + + public setRequiredVo( + path: string, + input: string | undefined, + factory: (value: string) => Result, + setter: (value: T) => void, + emptyMessage = "Value cannot be empty" + ): void { + if (input === undefined) { + return; + } + + if (input.trim() === "") { + this.addError(path, emptyMessage); + return; + } + + const result = factory(input); + if (result.isFailure) { + this.addError(path, result.error.message); + return; + } + + setter(result.data); + } + + public setNullableVo( + path: string, + input: string | null | undefined, + factory: (value: string) => Result, + setter: (value: ReturnType>) => void + ): void { + if (input === undefined) { + return; + } + + if (input === null) { + setter(this.wrapNullable(null)); + return; + } + + const result = factory(input); + if (result.isFailure) { + this.addError(path, result.error.message); + return; + } + + setter(this.wrapNullable(result.data)); + } + + public setNested( + _path: string, + input: TInput | undefined, + mapper: (value: TInput) => void + ): void { + if (input === undefined) { + return; + } + + mapper(input); + } + + /** + * Helper adaptable al tipo Maybe/nullable real del proyecto. + * Aquí lo dejo abstracto para no acoplar el ejemplo a una implementación concreta. + */ + private wrapNullable(value: T | null): T | null { + return value; + } +} diff --git a/modules/core/src/common/helpers/apply-validation-error-collection.ts b/modules/core/src/common/helpers/apply-validation-error-collection.ts new file mode 100644 index 00000000..79548380 --- /dev/null +++ b/modules/core/src/common/helpers/apply-validation-error-collection.ts @@ -0,0 +1,90 @@ +// packages/rdx-ui-or-core/src/forms/apply-validation-error-collection.ts +import type { ValidationErrorCollection } from "@repo/rdx-ddd"; +import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; + +export interface ApplyValidationErrorCollectionOptions { + /** + * Permite transformar paths del backend al path real del formulario. + * Ej: + * - "email_primary" -> "emailPrimary" + * - "recipient.name" -> "recipient.name" + */ + mapPath?: (backendPath: string) => Path | undefined; + + /** + * Nombre del campo raíz donde dejar errores globales/no mapeables. + * Opcional. Si no existe, simplemente no se asignan esos errores al form. + */ + rootFieldName?: Path; + + /** + * Si true, evita sobrescribir el mismo campo varias veces. + * Útil si backend manda varios errores sobre el mismo path. + */ + keepFirstErrorPerField?: boolean; +} + +const normalizeBackendPath = (path: string): string => { + return path + .trim() + .replace(/\[(\d+)\]/g, ".$1") // lines[0].name -> lines.0.name + .replace(/^\.+|\.+$/g, "") // quita puntos al principio/final + .replace(/\.{2,}/g, "."); // colapsa puntos dobles +}; + +const defaultMapPath = ( + backendPath: string +): Path | undefined => { + const normalized = normalizeBackendPath(backendPath); + return normalized as Path; +}; + +export const applyValidationErrorCollection = ( + form: UseFormReturn, + error: ValidationErrorCollection, + options?: ApplyValidationErrorCollectionOptions +) => { + const mapPath = options?.mapPath ?? defaultMapPath; + const rootFieldName = options?.rootFieldName; + const keepFirstErrorPerField = options?.keepFirstErrorPerField ?? true; + + const assignedFields = new Set(); + const globalMessages: string[] = []; + + for (const detail of error.details) { + const message = detail.message?.trim(); + const rawPath = detail.path?.trim(); + + if (!message) continue; + + if (!rawPath) { + globalMessages.push(message); + continue; + } + + const fieldPath = mapPath(rawPath); + + if (!fieldPath) { + globalMessages.push(message); + continue; + } + + if (keepFirstErrorPerField && assignedFields.has(fieldPath)) { + continue; + } + + form.setError(fieldPath, { + type: "server", + message, + }); + + assignedFields.add(fieldPath); + } + + if (globalMessages.length > 0 && rootFieldName) { + form.setError(rootFieldName, { + type: "server", + message: globalMessages.join("\n"), + }); + } +}; diff --git a/modules/core/src/common/helpers/index.ts b/modules/core/src/common/helpers/index.ts index c5f50933..0c35a1d5 100644 --- a/modules/core/src/common/helpers/index.ts +++ b/modules/core/src/common/helpers/index.ts @@ -1,3 +1,4 @@ +export * from "./apply-validation-error-collection"; export * from "./date-helper"; export * from "./dto-compare-helper"; export * from "./money-dto-helper"; diff --git a/modules/customers/src/web/shared/constants/customer.constants.ts b/modules/customers/src/web/shared/constants/customer.constants.ts index 180eaed4..a65625bb 100644 --- a/modules/customers/src/web/shared/constants/customer.constants.ts +++ b/modules/customers/src/web/shared/constants/customer.constants.ts @@ -1,12 +1,12 @@ export const COUNTRY_OPTIONS = [ - { value: "es", label: "España" }, - { value: "fr", label: "Francia" }, - { value: "de", label: "Alemania" }, - { value: "it", label: "Italia" }, - { value: "pt", label: "Portugal" }, - { value: "us", label: "Estados Unidos" }, - { value: "mx", label: "México" }, - { value: "ar", label: "Argentina" }, + { value: "ES", label: "España" }, + { value: "FR", label: "Francia" }, + { value: "DE", label: "Alemania" }, + { value: "IT", label: "Italia" }, + { value: "PT", label: "Portugal" }, + { value: "US", label: "Estados Unidos" }, + { value: "MX", label: "México" }, + { value: "AR", label: "Argentina" }, ] as const; export const LANGUAGE_OPTIONS = [ diff --git a/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts b/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts index 34c3c2fb..3ddd6d3b 100644 --- a/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts +++ b/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts @@ -33,7 +33,13 @@ export const useCustomerUpdateMutation = () => { const result = schema.safeParse(data); if (!result.success) { - throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); + console.log("Error de validación al actualizar 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: UpdateCustomerByIdResult = await updateCustomerById(dataSource, params); diff --git a/modules/customers/src/web/update/controllers/use-customer-update.controller.ts b/modules/customers/src/web/update/controllers/use-customer-update.controller.ts index b6d12722..c68a5814 100644 --- a/modules/customers/src/web/update/controllers/use-customer-update.controller.ts +++ b/modules/customers/src/web/update/controllers/use-customer-update.controller.ts @@ -1,3 +1,4 @@ +import { applyValidationErrorCollection } from "@erp/core"; import { useHookForm } from "@erp/core/hooks"; import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd"; import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; @@ -17,8 +18,11 @@ import { CustomerUpdateFormSchema, defaultCustomerUpdateForm, } from "../entities"; -import { buildCustomerUpdatePatch, buildUpdateCustomerByIdParams } from "../utils"; -import { focusFirstCustomerUpdateError } from "../utils/focus-first-customer-update-error"; +import { + buildCustomerUpdatePatch, + buildUpdateCustomerByIdParams, + focusFirstCustomerUpdateFormError, +} from "../utils"; export interface UseCustomerUpdateControllerOptions { onUpdated?(updated: Customer): void; @@ -28,6 +32,18 @@ export interface UseCustomerUpdateControllerOptions { errorToasts?: boolean; // mostrar o no toast automáticamente } +const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => { + if (isValidationErrorCollection(error)) { + return error; + } + + if (error instanceof Error) { + return error; + } + + return new Error("Unknown error"); +}; + export const useCustomerUpdateController = ( customerId?: string, options?: UseCustomerUpdateControllerOptions @@ -91,9 +107,20 @@ export const useCustomerUpdateController = ( return; } - const previousData = customerData; + //const previousData = customerData; const patchData = buildCustomerUpdatePatch(formData, form.formState.dirtyFields); + + if (Object.keys(patchData).length === 0) { + showWarningToast( + t("pages.update.no_changes.title", "Sin cambios"), + t("pages.update.no_changes.message", "No has realizado ningún cambio.") + ); + return; + } + + console.log("Parche de actualización construido:", patchData); + const params = buildUpdateCustomerByIdParams(customerId, patchData); console.log("Enviando actualización con params:", params); @@ -117,25 +144,49 @@ export const useCustomerUpdateController = ( options?.onUpdated?.(updated); } catch (error: unknown) { - const normalizedError = isValidationErrorCollection(error) - ? (error as ValidationErrorCollection) - : (error as Error); + const normalizedError = normalizeSubmitError(error); - // Revertir form a datos anteriores (si los hay) - form.reset( + // No revierto el form para que no se pierdan los cambios que + // ha hecho el usuario y no han sido guardados. + /*form.reset( previousData ? mapCustomerToCustomerUpdateForm(previousData) : defaultCustomerUpdateForm, { keepDirty: false } - ); + );*/ + + if (isValidationErrorCollection(normalizedError)) { + applyValidationErrorCollection(form, normalizedError); + + console.log("Errores de validación aplicados al form:", form.formState.errors); + + focusFirstCustomerUpdateFormError(form); + + if (options?.errorToasts !== false) { + showWarningToast( + t("pages.update.validation_error.title", "Revisa los campos"), + t( + "pages.update.validation_error.message", + "Hay errores de validación. Corrige los campos indicados." + ) + ); + } + + options?.onError?.(normalizedError, params); + return; + } if (options?.errorToasts !== false) { - showErrorToast(t("pages.update.error.title"), normalizedError.message); + showErrorToast( + t("pages.update.error.title", "No se pudo modificar el cliente"), + normalizedError.message || + t("common.errors.unexpected", "Ha ocurrido un error inesperado.") + ); } options?.onError?.(normalizedError, params); } }, (errors: FieldErrors) => { - focusFirstCustomerUpdateError(errors); + focusFirstCustomerUpdateFormError(form); showWarningToast( t("forms.validation.title", "Revisa los campos"), diff --git a/modules/customers/src/web/update/entities/customer-update-form.schema.ts b/modules/customers/src/web/update/entities/customer-update-form.schema.ts index 4a9a7eb7..5dc88c54 100644 --- a/modules/customers/src/web/update/entities/customer-update-form.schema.ts +++ b/modules/customers/src/web/update/entities/customer-update-form.schema.ts @@ -1,3 +1,13 @@ +import { + CountryCodeSchema, + CurrencyCodeSchema, + LandPhoneSchema, + LanguageCodeSchema, + MobilePhoneSchema, + PostalCodeSchema, + TinSchema, + URLSchema, +} from "@erp/core"; import { z } from "zod/v4"; /** @@ -18,9 +28,10 @@ import { z } from "zod/v4"; 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(), + tin: TinSchema, defaultTaxes: z.array(z.string()), @@ -28,22 +39,22 @@ export const CustomerUpdateFormSchema = z.object({ street2: z.string(), city: z.string(), province: z.string(), - postalCode: z.string(), - country: z.string().min(1, "El país es obligatorio"), + postalCode: PostalCodeSchema.or(z.literal("")), + country: CountryCodeSchema.or(z.literal("")), primaryEmail: z.email("Email inválido").or(z.literal("")), secondaryEmail: z.email("Email inválido").or(z.literal("")), - primaryPhone: z.string(), - secondaryPhone: z.string(), - primaryMobile: z.string(), - secondaryMobile: z.string(), + primaryPhone: LandPhoneSchema.or(z.literal("")), + secondaryPhone: LandPhoneSchema.or(z.literal("")), + primaryMobile: MobilePhoneSchema.or(z.literal("")), + secondaryMobile: MobilePhoneSchema.or(z.literal("")), - fax: z.string(), - website: z.url("URL inválida").or(z.literal("")), + fax: LandPhoneSchema.or(z.literal("")).or(z.literal("")), + website: URLSchema.or(z.literal("")).or(z.literal("")), - legalRecord: z.string(), + legalRecord: z.string().or(z.literal("")), - languageCode: z.string().min(1, "El idioma es obligatorio"), - currencyCode: z.string().min(1, "La moneda es obligatoria"), + languageCode: LanguageCodeSchema, + currencyCode: CurrencyCodeSchema, }); diff --git a/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx b/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx index 17bea23e..d576f8bc 100644 --- a/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-contact-fields.tsx @@ -33,13 +33,10 @@ export const CustomerContactEditor = ({ description={t("form_fields.email_primary.description")} disabled={disabled} label={t("form_fields.email_primary.label")} - leftIcon={ - - } + leftIcon={} name="primaryEmail" placeholder={t("form_fields.email_primary.placeholder")} readOnly={readOnly} - required typePreset="email" /> @@ -48,12 +45,7 @@ export const CustomerContactEditor = ({ description={t("form_fields.mobile_primary.description")} disabled={disabled} label={t("form_fields.mobile_primary.label")} - leftIcon={ - - } + leftIcon={} name="primaryMobile" placeholder={t("form_fields.mobile_primary.placeholder")} readOnly={readOnly} @@ -65,9 +57,7 @@ export const CustomerContactEditor = ({ description={t("form_fields.phone_primary.description")} disabled={disabled} label={t("form_fields.phone_primary.label")} - leftIcon={ - - } + leftIcon={} name="primaryPhone" placeholder={t("form_fields.phone_primary.placeholder")} readOnly={readOnly} @@ -81,9 +71,7 @@ export const CustomerContactEditor = ({ description={t("form_fields.email_secondary.description")} disabled={disabled} label={t("form_fields.email_secondary.label")} - leftIcon={ - - } + leftIcon={} name="secondaryEmail" placeholder={t("form_fields.email_secondary.placeholder")} readOnly={readOnly} @@ -95,12 +83,7 @@ export const CustomerContactEditor = ({ description={t("form_fields.mobile_secondary.description")} disabled={disabled} label={t("form_fields.mobile_secondary.label")} - leftIcon={ - - } + leftIcon={} name="secondaryMobile" placeholder={t("form_fields.mobile_secondary.placeholder")} readOnly={readOnly} @@ -111,9 +94,7 @@ export const CustomerContactEditor = ({ description={t("form_fields.phone_secondary.description")} disabled={disabled} label={t("form_fields.phone_secondary.label")} - leftIcon={ - - } + leftIcon={} name="secondaryPhone" placeholder={t("form_fields.phone_secondary.placeholder")} readOnly={readOnly} @@ -138,12 +119,7 @@ export const CustomerContactEditor = ({ description={t("form_fields.website.description")} disabled={disabled} label={t("form_fields.website.label")} - leftIcon={ - - } + leftIcon={} name="website" placeholder={t("form_fields.website.placeholder")} readOnly={readOnly} diff --git a/modules/customers/src/web/update/utils/build-customer.update-patch.ts b/modules/customers/src/web/update/utils/build-customer.update-patch.ts index 5000db2e..60a37847 100644 --- a/modules/customers/src/web/update/utils/build-customer.update-patch.ts +++ b/modules/customers/src/web/update/utils/build-customer.update-patch.ts @@ -25,9 +25,6 @@ export const buildCustomerUpdatePatch = ( if (!formHasAnyDirty(dirtyFields)) { return {}; } - - console.log("Campos sucios detectados:", dirtyFields); - const patch: CustomerUpdatePatch = {}; if (dirtyFields.reference) { diff --git a/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts b/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts index 201fbfb2..302675ca 100644 --- a/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts +++ b/modules/customers/src/web/update/utils/build-update-customer-by-id-params.ts @@ -23,38 +23,77 @@ export const buildUpdateCustomerByIdParams = ( id: string, patchData: CustomerUpdatePatch ): UpdateCustomerByIdParams => { + const data: Record = {}; + + // --- escalares --- + if (patchData.reference !== undefined) { + data.reference = patchData.reference; + } + + if (patchData.isCompany !== undefined) { + data.is_company = patchData.isCompany; + } + + if (patchData.name !== undefined) { + data.name = patchData.name; + } + + if (patchData.tradeName !== undefined) { + data.trade_name = patchData.tradeName; + } + + if (patchData.tin !== undefined) { + data.tin = patchData.tin; + } + + if (patchData.defaultTaxes !== undefined) { + data.default_taxes = patchData.defaultTaxes?.toString(); + } + + if (patchData.legalRecord !== undefined) { + data.legal_record = patchData.legalRecord; + } + + if (patchData.languageCode !== undefined) { + data.language_code = patchData.languageCode; + } + + if (patchData.currencyCode !== undefined) { + data.currency_code = patchData.currencyCode; + } + + // --- address --- + const address: Record = {}; + + if (patchData.street !== undefined) address.street = patchData.street; + if (patchData.street2 !== undefined) address.street2 = patchData.street2; + if (patchData.city !== undefined) address.city = patchData.city; + if (patchData.province !== undefined) address.province = patchData.province; + if (patchData.postalCode !== undefined) address.postal_code = patchData.postalCode; + if (patchData.country !== undefined) address.country = patchData.country; + + if (Object.keys(address).length > 0) { + data.address = address; + } + + // --- contact --- + const contact: Record = {}; + + if (patchData.primaryEmail !== undefined) contact.email_primary = patchData.primaryEmail; + if (patchData.secondaryEmail !== undefined) contact.email_secondary = patchData.secondaryEmail; + if (patchData.primaryPhone !== undefined) contact.phone_primary = patchData.primaryPhone; + if (patchData.secondaryPhone !== undefined) contact.phone_secondary = patchData.secondaryPhone; + if (patchData.primaryMobile !== undefined) contact.mobile_primary = patchData.primaryMobile; + if (patchData.secondaryMobile !== undefined) contact.mobile_secondary = patchData.secondaryMobile; + if (patchData.fax !== undefined) contact.fax = patchData.fax; + if (patchData.website !== undefined) contact.website = patchData.website; + + if (Object.keys(contact).length > 0) { + data.contact = contact; + } + return { id, - data: { - reference: patchData.reference, - is_company: patchData.isCompany, - name: patchData.name, - trade_name: patchData.tradeName, - tin: patchData.tin, - - default_taxes: patchData.defaultTaxes?.toString(), - - address: { - street: patchData.street, - street2: patchData.street2, - city: patchData.city, - province: patchData.province, - postal_code: patchData.postalCode, - country: patchData.country, - }, - contact: { - email_primary: patchData.primaryEmail, - email_secondary: patchData.secondaryEmail, - phone_primary: patchData.primaryPhone, - phone_secondary: patchData.secondaryPhone, - mobile_primary: patchData.primaryMobile, - mobile_secondary: patchData.secondaryMobile, - fax: patchData.fax, - website: patchData.website, - }, - legal_record: patchData.legalRecord, - language_code: patchData.languageCode, - currency_code: patchData.currencyCode, - }, + data, } satisfies UpdateCustomerByIdParams; }; diff --git a/modules/customers/src/web/update/utils/focus-first-customer-update-error.ts b/modules/customers/src/web/update/utils/focus-first-customer-update-error.ts deleted file mode 100644 index 14f6e9e6..00000000 --- a/modules/customers/src/web/update/utils/focus-first-customer-update-error.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { FieldErrors } from "react-hook-form"; - -import type { CustomerUpdateForm } from "../entities"; - -export const focusFirstCustomerUpdateError = (errors: FieldErrors) => { - const firstKey = Object.keys(errors)[0] as keyof CustomerUpdateForm | undefined; - - if (!firstKey) { - return; - } - - document.querySelector(`[name="${String(firstKey)}"]`)?.focus(); -}; diff --git a/modules/customers/src/web/update/utils/focus-first-customer-update-form-error.ts b/modules/customers/src/web/update/utils/focus-first-customer-update-form-error.ts new file mode 100644 index 00000000..84e8f75a --- /dev/null +++ b/modules/customers/src/web/update/utils/focus-first-customer-update-form-error.ts @@ -0,0 +1,14 @@ +import type { UseFormReturn } from "react-hook-form"; + +import type { CustomerUpdateForm } from "../entities"; + +export const focusFirstCustomerUpdateFormError = (form: UseFormReturn) => { + const errors = form.formState.errors; + const firstKey = Object.keys(errors)[0] as keyof CustomerUpdateForm | undefined; + + if (firstKey) { + form.setFocus(firstKey); + } + + return; +}; diff --git a/modules/customers/src/web/update/utils/index.ts b/modules/customers/src/web/update/utils/index.ts index 2fc76ab3..ee307e2a 100644 --- a/modules/customers/src/web/update/utils/index.ts +++ b/modules/customers/src/web/update/utils/index.ts @@ -1,3 +1,3 @@ export * from "./build-customer.update-patch"; export * from "./build-update-customer-by-id-params"; -export * from "./focus-first-customer-update-error"; +export * from "./focus-first-customer-update-form-error";