diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 51929c33..2417d0a5 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -59,7 +59,7 @@ export const App = () => { - + {import.meta.env.DEV && } diff --git a/modules/core/src/common/dto/tin.dto.ts b/modules/core/src/common/dto/tin.dto.ts index d3fae216..9593ac5f 100644 --- a/modules/core/src/common/dto/tin.dto.ts +++ b/modules/core/src/common/dto/tin.dto.ts @@ -16,8 +16,8 @@ import { z } from "zod/v4"; export const TinSchema = z .string() .trim() - .min(1, "TIN cannot be empty.") - .max(32, "TIN is too long.") + .min(2, "TIN cannot be empty.") + .max(10, "TIN is too long.") .regex(/^(?=.*[A-Za-z0-9])[A-Za-z0-9.\-/ ]+$/, "TIN has an invalid format."); export type TinDTO = z.infer; diff --git a/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts b/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts index 2b8c4326..be18279a 100644 --- a/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts +++ b/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts @@ -70,8 +70,15 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => { data: TData, params?: Record ): Promise => { + const url = `${resource}/${id}`; + + console.log("Axios updateOne => ", { + url, + data, + }); + const res = await client.put, TData>( - `${resource}/${id}`, + url, data, params as AxiosRequestConfig ); diff --git a/modules/core/src/web/lib/helpers/build-top-level-form-dirty-patch.ts b/modules/core/src/web/lib/helpers/build-top-level-form-dirty-patch.ts new file mode 100644 index 00000000..83c62ab0 --- /dev/null +++ b/modules/core/src/web/lib/helpers/build-top-level-form-dirty-patch.ts @@ -0,0 +1,212 @@ +import type { FieldNamesMarkedBoolean } from "react-hook-form"; + +/** + * Devuelve el valor sin aplicar ninguna normalización. + * + * Propósito: + * - Permitir pasar valores ya alineados con el contrato de salida. + * - Evitar transformaciones innecesarias en casos triviales. + * + * Uso típico: + * - Campos que ya tienen exactamente el shape del patch. + * - Reemplazos completos de colecciones (`items`). + * + * Reglas: + * - Solo usar cuando `Input === Output` (mismo tipo semántico). + * - No usar si hay diferencias de contrato (ej: `""` vs `null`). + * - No usar para ocultar transformaciones necesarias. + * + * Ejemplos: + * ```ts + * asIs(10) // 10 + * asIs(true) // true + * + * // Caso válido: array ya en formato patch + * asIs([{ id: "1", description: null }]) + * ``` + * + * Ejemplo incorrecto: + * ```ts + * // ❌ description debería ser nullableString + * asIs({ description: "" }) + * ``` + * + * @typeParam T - Tipo de entrada y salida. + * @param value - Valor sin normalizar. + * @returns Mismo valor sin modificaciones. + */ +export const asIs = (value: T): T => value; + +/** + * Convierte un string en un valor requerido normalizado. + * + * Reglas: + * - Elimina espacios laterales (`trim`) + * - Nunca devuelve `null` ni `undefined` + * - No valida contenido (eso pertenece a capa superior) + * + * Ejemplos: + * ```ts + * toRequiredString(" ABC ") // "ABC" + * toRequiredString("") // "" + * ``` + * + * @param value - Valor de entrada desde UI/form. + * @returns String normalizado. + */ +export const toRequiredString = (value: string): string => { + return value.trim(); +}; + +/** + * Convierte un string en un valor nullable. + * + * Reglas: + * - Elimina espacios laterales (`trim`) + * - `""` o whitespace → `null` + * - valor con contenido → string normalizado + * + * Ejemplos: + * ```ts + * toNullableString(" ABC ") // "ABC" + * toNullableString("") // null + * toNullableString(" ") // null + * ``` + * + * @param value - Valor de entrada desde UI/form. + * @returns String normalizado o `null`. + */ +export const toNullableString = (value: string): string | null => { + const trimmed = value.trim(); + + return trimmed === "" ? null : trimmed; +}; + +/** + * Normaliza un número requerido. + * + * Reglas: + * - Devuelve el mismo valor + * - No valida rango ni precisión + * - No convierte `null` ni `undefined` + * + * Uso típico: + * - Campos numéricos obligatorios ya validados por el formulario + * + * Ejemplos: + * ```ts + * toNumber(10) // 10 + * toNumber(0) // 0 + * ``` + * + * @param value - Número de entrada. + * @returns Número sin transformación. + */ +export const toNumber = (value: number): number => { + return value; +}; + +/** + * Normaliza un número nullable. + * + * Reglas: + * - `null` → `null` + * - número válido → mismo valor + * - No convierte `0` a null + * + * Uso típico: + * - Campos opcionales numéricos + * + * Ejemplos: + * ```ts + * toNullableNumber(10) // 10 + * toNullableNumber(null) // null + * ``` + * + * @param value - Número o null. + * @returns Número o `null`. + */ +export const toNullableNumber = (value: number | null): number | null => { + return value; +}; + +/** + * Normaliza un booleano. + * + * Reglas: + * - Devuelve el mismo valor + * - No realiza coerción (`"true"` → ❌) + * + * Uso típico: + * - Flags de UI ya tipados correctamente + * + * Ejemplos: + * ```ts + * toBoolean(true) // true + * toBoolean(false) // false + * ``` + * + * @param value - Booleano de entrada. + * @returns Booleano sin transformación. + */ +export const toBoolean = (value: boolean): boolean => { + return value; +}; + +// ----- + +type FieldNormalizer = (value: TInput) => TOutput; + +export type DirtyPatchSpec = { + [K in keyof TForm & keyof TPatch]?: FieldNormalizer; +}; + +/** + * Comprueba si un campo top-level fue modificado. + * + * Se encapsula el cast porque `FieldNamesMarkedBoolean` puede representar + * estructuras anidadas, pero este builder solo soporta claves top-level. + */ +const isTopLevelDirty = ( + dirtyFields: FieldNamesMarkedBoolean, + key: keyof TForm +): boolean => { + return Boolean((dirtyFields as Partial>)[key]); +}; + +/** + * Construye un patch top-level a partir de `formData`, `dirtyFields` y una spec de normalizadores. + * + * Solo procesa claves declaradas en `spec`. + * Solo incluye campos marcados como dirty en primer nivel. + * No soporta paths anidados ni merge granular de arrays. + * + * @example + * ```ts + * const patchSpec = { + * name: requiredString, + * notes: nullableString, + * items: raw, + * } satisfies DirtyPatchSpec; + * + * const patch = buildTopLevelDirtyPatch(formData, dirtyFields, patchSpec); + * ``` + */ +export const buildTopLevelFormDirtyPatch = ( + formData: TForm, + dirtyFields: FieldNamesMarkedBoolean, + spec: DirtyPatchSpec +): TPatch => { + const patch: Partial = {}; + + for (const key of Object.keys(spec) as Array) { + if (!isTopLevelDirty(dirtyFields, key)) continue; + + const normalize = spec[key]; + if (!normalize) continue; + + patch[key] = normalize(formData[key]) as TPatch[typeof key]; + } + + return patch as TPatch; +}; diff --git a/modules/core/src/web/lib/helpers/index.ts b/modules/core/src/web/lib/helpers/index.ts index 17e42d8e..01a6c7ef 100644 --- a/modules/core/src/web/lib/helpers/index.ts +++ b/modules/core/src/web/lib/helpers/index.ts @@ -1,2 +1,3 @@ +export * from "./build-top-level-form-dirty-patch"; export * from "./form-utils"; export * from "./http-url-utils"; diff --git a/modules/core/src/web/manifest.ts b/modules/core/src/web/manifest.ts index 4af731e3..0b5f1338 100644 --- a/modules/core/src/web/manifest.ts +++ b/modules/core/src/web/manifest.ts @@ -1,4 +1,4 @@ -import { IModuleClient, ModuleClientParams } from "./lib"; +import type { IModuleClient, ModuleClientParams } from "./lib"; export const MODULE_NAME = "Core"; const MODULE_VERSION = "1.0.0"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts index f45b9068..532766ad 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts @@ -50,6 +50,8 @@ export class ProformaUpdater implements IProformaUpdater { return Result.fail(updateResult.error); } + console.log(proforma); + // Persistir cambios const saveResult = await this.repository.update(proforma, transaction); diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts index 498aba81..0f658e3d 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts @@ -16,23 +16,19 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder private readonly itemsBuilder: IProformaItemsFullSnapshotBuilder, private readonly recipientBuilder: IProformaRecipientFullSnapshotBuilder, private readonly taxesBuilder: IProformaTaxesFullSnapshotBuilder + //private readonly paymentMethodBuilder: IProformaPaymentMethodFullSnapshotBuilder ) {} toOutput(proforma: Proforma): GetProformaByIdResponseDTO { const items = this.itemsBuilder.toOutput(proforma.items); const recipient = this.recipientBuilder.toOutput(proforma); const taxes = this.taxesBuilder.toOutput(proforma.taxes()); + //const paymentMethod = this.paymentMethodBuilder.toOutput(proforma.paymentMethod); - const payment = proforma.paymentMethod.match( - (payment) => { - const { id, payment_description } = payment.toObjectString(); - return { - id: id, - description: payment_description, - }; - }, - () => null - ); + const paymentMethod = maybeToNullable(proforma.paymentMethodId, (value) => ({ + id: value.toString(), + description: "", + })); const allTotals = proforma.totals(); @@ -59,7 +55,7 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder linked_invoice_id: maybeToNullable(proforma.linkedInvoiceId, (value) => value.toString()), - payment_method: payment, + payment_method: paymentMethod, subtotal_amount: allTotals.subtotalAmount.toObjectString(), items_discount_amount: allTotals.itemsDiscountAmount.toObjectString(), diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts index f30e2203..080fe87b 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-items-full-snapshot-builder.ts @@ -1,9 +1,7 @@ import type { ISnapshotBuilder } from "@erp/core/api"; import type { ProformaItemDetailDTO } from "@erp/customer-invoices/common"; import { - maybeToEmptyMoneyObjectString, maybeToEmptyPercentageObjectString, - maybeToEmptyQuantityObjectString, maybeToEmptyString, maybeToNullable, } from "@repo/rdx-ddd"; @@ -17,6 +15,7 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps private mapItem(proformaItem: ProformaItem, index: number): ProformaItemDetailDTO { const allAmounts = proformaItem.totals(); const isValued = proformaItem.isValued(); + const currencyCode = proformaItem.currencyCode.code; return { id: proformaItem.id.toPrimitive(), @@ -25,54 +24,58 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps description: maybeToNullable(proformaItem.description, (value) => value.toString()), - quantity: maybeToEmptyQuantityObjectString(proformaItem.quantity), - unit_amount: maybeToEmptyMoneyObjectString(proformaItem.unitAmount), + quantity: maybeToNullable(proformaItem.quantity, (value) => value.toObjectString()), + unit_amount: maybeToNullable(proformaItem.unitAmount, (value) => value.toObjectString()), subtotal_amount: isValued ? allAmounts.subtotalAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : ItemAmount.zero(currencyCode).toObjectString(), - item_discount_percentage: maybeToEmptyPercentageObjectString( - proformaItem.itemDiscountPercentage + item_discount_percentage: maybeToNullable(proformaItem.itemDiscountPercentage, (value) => + value.toObjectString() ), item_discount_amount: isValued ? allAmounts.itemDiscountAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : ItemAmount.zero(currencyCode).toObjectString(), global_discount_percentage: proformaItem.globalDiscountPercentage.toObjectString(), global_discount_amount: isValued ? allAmounts.globalDiscountAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : ItemAmount.zero(currencyCode).toObjectString(), total_discount_amount: isValued ? allAmounts.totalDiscountAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : ItemAmount.zero(currencyCode).toObjectString(), taxable_amount: isValued ? allAmounts.taxableAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : ItemAmount.zero(currencyCode).toObjectString(), iva_code: maybeToEmptyString(proformaItem.ivaCode()), iva_percentage: maybeToEmptyPercentageObjectString(proformaItem.ivaPercentage()), - iva_amount: isValued ? allAmounts.ivaAmount.toObjectString() : ItemAmount.EMPTY_MONEY_OBJECT, + iva_amount: isValued + ? allAmounts.ivaAmount.toObjectString() + : ItemAmount.zero(currencyCode).toObjectString(), rec_code: maybeToEmptyString(proformaItem.recCode()), rec_percentage: maybeToEmptyPercentageObjectString(proformaItem.recPercentage()), - rec_amount: isValued ? allAmounts.recAmount.toObjectString() : ItemAmount.EMPTY_MONEY_OBJECT, + rec_amount: isValued + ? allAmounts.recAmount.toObjectString() + : ItemAmount.zero(currencyCode).toObjectString(), retention_code: maybeToEmptyString(proformaItem.retentionCode()), retention_percentage: maybeToEmptyPercentageObjectString(proformaItem.retentionPercentage()), retention_amount: isValued ? allAmounts.retentionAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : ItemAmount.zero(currencyCode).toObjectString(), taxes_amount: isValued ? allAmounts.taxesAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : ItemAmount.zero(currencyCode).toObjectString(), total_amount: isValued ? allAmounts.totalAmount.toObjectString() - : ItemAmount.EMPTY_MONEY_OBJECT, + : ItemAmount.zero(currencyCode).toObjectString(), }; } diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index 411d9381..254dc5c9 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -151,7 +151,9 @@ export class Proforma extends AggregateRoot implements IP return Result.ok(proforma); } - private static validateCreateProps(props: IProformaCreateProps): Result { + private static validateCreateProps( + props: IProformaCreateProps | ProformaInternalProps + ): Result { return Result.ok(); } @@ -169,7 +171,14 @@ export class Proforma extends AggregateRoot implements IP ...otherProps, }; + console.log(candidateProps); + // Validacciones + const validationResult = Proforma.validateCreateProps(candidateProps); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } // Aplicar cambios Object.assign(this.props, candidateProps); diff --git a/modules/customer-invoices/src/common/dto/shared/proforma/proforma-item-detail.dto.ts b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-item-detail.dto.ts index c924ef1f..01ad392b 100644 --- a/modules/customer-invoices/src/common/dto/shared/proforma/proforma-item-detail.dto.ts +++ b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-item-detail.dto.ts @@ -10,12 +10,12 @@ export const ProformaItemDetailSchema = z.object({ position: ItemPositionSchema, description: z.string().nullable(), - quantity: QuantitySchema, - unit_amount: MoneySchema, + quantity: QuantitySchema.nullable(), + unit_amount: MoneySchema.nullable(), subtotal_amount: MoneySchema, - item_discount_percentage: PercentageSchema, + item_discount_percentage: PercentageSchema.nullable(), item_discount_amount: MoneySchema, global_discount_percentage: PercentageSchema, diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts index f7216764..ea241916 100644 --- a/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-by-id.adapter.ts @@ -24,7 +24,6 @@ export const GetProformaByIdAdapter = { return { id: dto.id, companyId: dto.company_id, - isProforma: dto.is_proforma === "1", invoiceNumber: dto.invoice_number, status: dto.status as ProformaStatus, @@ -69,6 +68,8 @@ const mapItem = (dto: GetProformaByIdResponseDTO["items"][number]): ProformaItem return { id: dto.id, position: Number(dto.position), + isValued: dto.is_valued, + description: dto.description, quantity: QuantityDTOHelper.toNumber(dto.quantity), diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/proforma-item.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-item.entity.ts index 4b1ba718..c155f927 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/proforma-item.entity.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-item.entity.ts @@ -7,6 +7,8 @@ export interface ProformaItem { id: string; position: number; + isValued: boolean; + description: string; quantity: number; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-update-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-update-mutation.ts index b40e31f9..eaef1371 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-update-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-update-mutation.ts @@ -33,7 +33,12 @@ export const useProformaUpdateMutation = () => { throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); } + console.log("Hola!!!"); + const dto: UpdateProformaByIdResult = await updateProformaById(dataSource, params); + + console.log(dto); + return GetProformaByIdAdapter.fromDto(dto); }, onSuccess: async (proforma) => { diff --git a/modules/customer-invoices/src/web/proformas/update/adapters/proforma-items-to-proforma-items-update-form.adapter.ts b/modules/customer-invoices/src/web/proformas/update/adapters/proforma-items-to-proforma-items-update-form.adapter.ts index 24a29cd4..35810851 100644 --- a/modules/customer-invoices/src/web/proformas/update/adapters/proforma-items-to-proforma-items-update-form.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/update/adapters/proforma-items-to-proforma-items-update-form.adapter.ts @@ -2,7 +2,7 @@ import type { ProformaItem } from "../../shared"; import type { ProformaItemUpdateForm } from "../entities"; /** - * Mapea un cliente a un formulario de actualización de cliente. + * Mapea una linea de proforma a un formulario de actualización de cliente. * * @param proforma * @returns @@ -14,6 +14,7 @@ export const mapProformaItemsToProformaItemsUpdateForm = ( return { id: item.id, position: item.position, + isValued: item.isValued, description: item.description, quantity: item.quantity, unitAmount: item.unitAmount, diff --git a/modules/customer-invoices/src/web/proformas/update/adapters/proforma-to-proforma-update-form.adapter.ts b/modules/customer-invoices/src/web/proformas/update/adapters/proforma-to-proforma-update-form.adapter.ts index 30fa0d07..727fb584 100644 --- a/modules/customer-invoices/src/web/proformas/update/adapters/proforma-to-proforma-update-form.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/update/adapters/proforma-to-proforma-update-form.adapter.ts @@ -4,7 +4,7 @@ import type { ProformaUpdateForm } from "../entities"; import { mapProformaItemsToProformaItemsUpdateForm } from "./proforma-items-to-proforma-items-update-form.adapter"; /** - * Mapea un cliente a un formulario de actualización de cliente. + * Mapea una proforma a un formulario de actualización de proforma. * * @param proforma * @returns diff --git a/modules/customer-invoices/src/web/proformas/update/adapters/proforma-to-selected-customer.adapter.ts b/modules/customer-invoices/src/web/proformas/update/adapters/proforma-to-selected-customer.adapter.ts index 61f55528..99d4fae4 100644 --- a/modules/customer-invoices/src/web/proformas/update/adapters/proforma-to-selected-customer.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/update/adapters/proforma-to-selected-customer.adapter.ts @@ -2,6 +2,13 @@ import type { CustomerSelectionOption } from "@erp/customers/common"; import type { Proforma } from "../../shared/entities"; +/** + * Extrae de la proforma el cliente y lo trata como cliente seleccionado para esa proforma. + * + * @param proforma + * @returns + */ + export const mapProformaToSelectedCustomer = ( proforma?: Proforma | null ): CustomerSelectionOption | null => { diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts index 0ed38981..d37ffa89 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts @@ -1,6 +1,8 @@ +import { applyValidationErrorCollection } from "@erp/core"; import { formHasAnyDirty } from "@erp/core/client"; import { useHookForm } from "@erp/core/hooks"; import type { CustomerSelectionOption } from "@erp/customers"; +import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd"; import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; import { useEffect, useId, useMemo, useState } from "react"; import type { FieldErrors } from "react-hook-form"; @@ -15,7 +17,7 @@ import { buildProformaUpdateDefault, buildProformaUpdatePatch, buildUpdateProformaByIdParams, - focusFirstProformaUpdateError, + focusFirstProformaUpdateFormError, } from "../utils"; import { useUpdateProformaItemsController } from "./use-update-proforma-items-controller"; @@ -28,6 +30,18 @@ export interface UseUpdateProformaControllerOptions { errorToasts?: boolean; } +const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => { + if (isValidationErrorCollection(error)) { + return error; + } + + if (error instanceof Error) { + return error; + } + + return new Error("Unknown error"); +}; + export const useUpdateProformaController = ( proformaId?: string, options?: UseUpdateProformaControllerOptions @@ -66,6 +80,7 @@ export const useUpdateProformaController = ( const [selectedCustomer, setSelectedCustomer] = useState(null); + /** Reiniciar el form al recibir datos */ useEffect(() => { if (!proformaData) return; @@ -115,8 +130,6 @@ export const useUpdateProformaController = ( return; } - console.log(form.formState.dirtyFields); - if (!formHasAnyDirty(form.formState.dirtyFields)) { showWarningToast( t("proformas.update.no_changes.title"), @@ -125,11 +138,24 @@ export const useUpdateProformaController = ( return; } - const previousData = proformaData; + //const previousData = proformaData; const patchData = buildProformaUpdatePatch(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 = buildUpdateProformaByIdParams(proformaId, patchData); + console.log("Enviando actualización con params:", params); + try { // Enviamos cambios al servidor const updated = await mutateAsync(params); @@ -151,38 +177,59 @@ export const useUpdateProformaController = ( options?.onUpdated?.(updated); } catch (error: unknown) { - const normalizedError = - error instanceof Error ? error : new Error(t("pages.update.error.unknown")); + const normalizedError = normalizeSubmitError(error); - 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 ? mapProformaToProformaUpdateForm(previousData) : buildProformaUpdateDefault(), { keepDirty: false } - ); setSelectedCustomer(previousData ? mapProformaToSelectedCustomer(previousData) : null); + + );*/ + + if (isValidationErrorCollection(normalizedError)) { + applyValidationErrorCollection(form, normalizedError); + + console.log("Errores de validación aplicados al form:", form.formState.errors); + + focusFirstProformaUpdateFormError(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("proformas.update.error.title"), normalizedError.message); } - options?.onError?.(normalizedError, params); } }, (errors: FieldErrors) => { console.log(errors); - focusFirstProformaUpdateError(errors); + focusFirstProformaUpdateFormError(form); showWarningToast( - t("proformas.update.validation.title"), - t("proformas.update.validation.message") + 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
- const onSubmit = (event: React.FormEvent) => { + const onSubmit = (event: React.SubmitEvent) => { event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM submitHandler(event); }; diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.entity.ts index eb1fb54b..ba36d880 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.entity.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.entity.ts @@ -1,8 +1,12 @@ export interface ProformaItemUpdateForm { id: string; position: number; + isValued: boolean; + description: string; + quantity: number | null; unitAmount: number | null; + itemDiscountPercentage: number | null; } diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.schema.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.schema.ts index 63255999..c2a5c29d 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.schema.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-form.schema.ts @@ -17,10 +17,13 @@ import { z } from "zod/v4"; export const ProformaItemUpdateFormSchema = z.object({ id: z.uuid(), position: z.number().int().nonnegative(), + isValued: z.boolean(), - description: z.string().trim(), - quantity: z.number().positive().nullable(), + description: z.string(), + + quantity: z.number().nullable(), unitAmount: z.number().nonnegative().nullable(), + itemDiscountPercentage: z.number().min(0).max(100).nullable(), }); diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-patch.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-patch.entity.ts index 600b8175..5247f784 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-patch.entity.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-item-update-patch.entity.ts @@ -1,8 +1,26 @@ +/** + * ProformaItemUpdatePatch representa los cambios que se han hecho + * en el formulario de edición y que se aplicarán al item de una proforma. + * + * Reglas: + * - `undefined` => no enviar + * - `null` => borrar explícitamente + * - `""` no se usa aquí para campos anulables; el builder lo convierte a null + * - mantiene naming camelCase porque sigue siendo contrato frontend + * + * OJO => no hay campos opcionales por que la actualización en los + * items de una proforma es COMPLETA. + */ + export interface ProformaItemUpdatePatch { id: string; position: number; - description: string; + isValued: boolean; + + description: string | null; + quantity: number | null; unitAmount: number | null; + itemDiscountPercentage: number | null; } diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts index 857bc6df..f6380476 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.entity.ts @@ -1,5 +1,5 @@ /** - * ProformaUpdateForm representa el shape de datos del formulario de actualización de proformas. + * ProformaUpdateForm representa los datos del formulario de actualización de proformas. * Es decir, los campos que se muestran en el formulario y que el usuario puede editar. * * Es específico de la UI y no tiene por qué coincidir diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts index 295e66bc..2b72aea2 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-form.schema.ts @@ -1,3 +1,4 @@ +import { CurrencyCodeSchema, LanguageCodeSchema } from "@erp/core"; import { z } from "zod/v4"; import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema"; @@ -17,23 +18,23 @@ import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema */ export const ProformaUpdateFormSchema = z.object({ - series: z.string().default(""), + series: z.string().or(z.literal("")), - invoiceDate: z.string().default(""), - operationDate: z.string().default(""), + invoiceDate: z.string(), + operationDate: z.string().or(z.literal("")), - customerId: z.string().default(""), + customerId: z.string(), - description: z.string().default(""), - reference: z.string().default(""), - notes: z.string().default(""), + description: z.string(), + reference: z.string().or(z.literal("")), + notes: z.string().or(z.literal("")), - languageCode: z.string().min(1, "Debe indicar un idioma").default("es"), - currencyCode: z.string().min(1, "Debe indicar una moneda").default("EUR"), + languageCode: LanguageCodeSchema, + currencyCode: CurrencyCodeSchema, - globalDiscountPercentage: z.number().default(0), + globalDiscountPercentage: z.number(), - paymentMethod: z.string().default(""), + paymentMethod: z.string().or(z.literal("")), items: z.array(ProformaItemUpdateFormSchema), }); diff --git a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-patch.entity.ts b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-patch.entity.ts index 36c4a492..798109c7 100644 --- a/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-patch.entity.ts +++ b/modules/customer-invoices/src/web/proformas/update/entities/proforma-update-patch.entity.ts @@ -1,17 +1,34 @@ -import type { ProformaUpdateForm } from "./proforma-update-form.entity"; - /** - * ProformaUpdatePatch representa los cambios que se van a aplicar a una proforma. - * Se representa con las mismas propiedades que ProformaUpdateForm, - * pero todas ellas son opcionales. - * - * A la API solo hay que enviar los campos que han cambiado. + * ProformaUpdatePatch representa los cambios que se han hecho + * en el formulario de edición y que se aplicarán a la proforma. * * Reglas: - * - debe ser un Partial de ProformaUpdateForm - * - 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 + * - `undefined` => no enviar + * - `null` => borrar explícitamente + * - `""` no se usa aquí para campos anulables; el builder lo convierte a null + * - mantiene naming camelCase porque sigue siendo contrato frontend */ -export type ProformaUpdatePatch = Partial; +import type { ProformaItemUpdatePatch } from "./proforma-item-update-patch.entity"; + +export type ProformaUpdatePatch = { + series?: string | null; + + invoiceDate?: string; + operationDate?: string | null; + + customerId?: string; + + reference?: string | null; + description?: string | null; + notes?: string | null; + + globalDiscountPercentage?: number; + + paymentMehodId?: string; + + languageCode?: string; + currencyCode?: string; + + items?: ProformaItemUpdatePatch[]; +}; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx index e52064f6..aca168a3 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-header-editor.tsx @@ -3,6 +3,7 @@ import { FormSectionCard, FormSectionGrid, SelectField, + TextAreaField, TextField, } from "@repo/rdx-ui/components"; @@ -72,6 +73,17 @@ export const ProformaUpdateHeaderEditor = ({ placeholder={t("form_fields.proformas.description.placeholder")} readOnly={readOnly} /> + + + ); diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-item-row-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-item-row-editor.tsx index 27fcaccd..ab86f489 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-item-row-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-item-row-editor.tsx @@ -1,4 +1,10 @@ -import { AmountField, PercentageField, QuantityField, TextField } from "@repo/rdx-ui/components"; +import { + AmountField, + CheckboxField, + PercentageField, + QuantityField, + TextField, +} from "@repo/rdx-ui/components"; import { Button, Card, CardContent } from "@repo/shadcn-ui/components"; import { CopyIcon, MoveDownIcon, MoveUpIcon, Trash2Icon } from "lucide-react"; @@ -34,6 +40,12 @@ export const ProformaUpdateItemRowEditor = ({
+ + diff --git a/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts index 9ed6975a..ed9535ff 100644 --- a/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts +++ b/modules/customer-invoices/src/web/proformas/update/utils/build-update-proforma-by-id-params.ts @@ -1,5 +1,23 @@ +import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/core"; +import { ObjectHelper } from "@repo/rdx-utils"; + import type { UpdateProformaByIdParams } from "../../shared/api"; -import type { ProformaUpdatePatch } from "../entities"; +import type { ProformaItemUpdatePatch, ProformaUpdatePatch } from "../entities"; + +/** + * Convierte el patch del formulario de actualización de proforma + * en los parámetros esperados por la API. + * + * Reglas: + * - Respeta campos enviados explícitamente como `null`. + * - Convierte camelCase frontend a snake_case API. + * - Convierte porcentajes, cantidades e importes a objetos `{ value, scale }`. + * - `items` se envía como reemplazo completo de la colección. + * + * @param id - Identificador de la proforma. + * @param patch - Patch construido desde los campos dirty del formulario. + * @returns Parámetros para la llamada de actualización. + */ export const buildUpdateProformaByIdParams = ( id: string, @@ -9,38 +27,86 @@ export const buildUpdateProformaByIdParams = ( throw new Error("proformaId is required"); } - const data: UpdateProformaByIdParams["data"] = { - series: patch.series, + const data: UpdateProformaByIdParams["data"] = {}; - invoice_date: patch.invoiceDate, - operation_date: patch.operationDate, + if (ObjectHelper.hasOwn(patch, "series")) { + data.series = patch.series; + } - customer_id: patch.customerId, + if (ObjectHelper.hasOwn(patch, "invoiceDate")) { + data.invoice_date = patch.invoiceDate; + } - reference: patch.reference, - description: patch.description, - notes: patch.notes, + if (ObjectHelper.hasOwn(patch, "operationDate")) { + data.operation_date = patch.operationDate; + } - language_code: patch.languageCode, - currency_code: patch.currencyCode, + if (ObjectHelper.hasOwn(patch, "customerId")) { + data.customer_id = patch.customerId; + } - items: patch.items?.map((item) => ({ - id: item.id, - position: String(item.position), - description: item.description, - quantity: item.quantity === null ? undefined : String(item.quantity), - unit_amount: item.unitAmount === null ? undefined : String(item.unitAmount), - item_discount_percentage: - item.itemDiscountPercentage === null - ? undefined - : { value: String(item.itemDiscountPercentage), scale: "2" }, - })), - }; + if (ObjectHelper.hasOwn(patch, "reference")) { + data.reference = patch.reference; + } + + if (ObjectHelper.hasOwn(patch, "description")) { + data.description = patch.description; + } + + if (ObjectHelper.hasOwn(patch, "notes")) { + data.notes = patch.notes; + } + + if (ObjectHelper.hasOwn(patch, "globalDiscountPercentage")) { + data.global_discount_percentage = PercentageDTOHelper.fromNumber( + patch.globalDiscountPercentage!, + 2 + ); + } + + if (ObjectHelper.hasOwn(patch, "languageCode")) { + data.language_code = patch.languageCode; + } + + if (ObjectHelper.hasOwn(patch, "currencyCode")) { + data.currency_code = patch.currencyCode; + } + + if (ObjectHelper.hasOwn(patch, "items")) { + data.items = patch.items?.map(toProformaItemUpdateDTO); + } return { id, - data: Object.fromEntries( - Object.entries(data).filter(([, value]) => value !== undefined) - ) satisfies UpdateProformaByIdParams["data"], + data, + } satisfies UpdateProformaByIdParams; +}; + +const toProformaItemUpdateDTO = ( + item: ProformaItemUpdatePatch +): NonNullable[number] => { + const quantity = + item.quantity === null ? null : QuantityDTOHelper.fromNumber(item.quantity, 4).value; + + const unit_amount = + item.unitAmount === null ? null : MoneyDTOHelper.fromNumber(item.unitAmount, "EUR", 2).value; + + const is_valued = item.isValued; + + return { + position: item.position, + is_valued, + + description: item.description, + + quantity, + unit_amount, + + item_discount_percentage: + item.itemDiscountPercentage === null + ? null + : PercentageDTOHelper.fromNumber(item.itemDiscountPercentage, 2), + + taxes: "#;#;#", }; }; diff --git a/modules/customer-invoices/src/web/proformas/update/utils/focus-first-proforma-update-error.ts b/modules/customer-invoices/src/web/proformas/update/utils/focus-first-proforma-update-error.ts deleted file mode 100644 index 7407e6c4..00000000 --- a/modules/customer-invoices/src/web/proformas/update/utils/focus-first-proforma-update-error.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { FieldErrors } from "react-hook-form"; - -import type { ProformaUpdateForm } from "../entities"; - -export const focusFirstProformaUpdateError = (errors: FieldErrors) => { - const firstKey = Object.keys(errors)[0] as keyof ProformaUpdateForm | undefined; - - if (!firstKey) { - return; - } - - document.querySelector(`[name="${String(firstKey)}"]`)?.focus(); -}; diff --git a/modules/customer-invoices/src/web/proformas/update/utils/focus-first-proforma-update-form-error.ts b/modules/customer-invoices/src/web/proformas/update/utils/focus-first-proforma-update-form-error.ts new file mode 100644 index 00000000..80c3a3e8 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/utils/focus-first-proforma-update-form-error.ts @@ -0,0 +1,14 @@ +import type { UseFormReturn } from "react-hook-form"; + +import type { ProformaUpdateForm } from "../entities"; + +export const focusFirstProformaUpdateFormError = (form: UseFormReturn) => { + const errors = form.formState.errors; + const firstKey = Object.keys(errors)[0] as keyof ProformaUpdateForm | undefined; + + if (firstKey) { + form.setFocus(firstKey); + } + + return; +}; diff --git a/modules/customer-invoices/src/web/proformas/update/utils/index.ts b/modules/customer-invoices/src/web/proformas/update/utils/index.ts index 155a3165..b948fe1d 100644 --- a/modules/customer-invoices/src/web/proformas/update/utils/index.ts +++ b/modules/customer-invoices/src/web/proformas/update/utils/index.ts @@ -1,5 +1,6 @@ export * from "./build-proforma-item-update-default"; export * from "./build-proforma-update-default"; +export * from "./build-proforma-update-default"; export * from "./build-proforma-update-patch"; export * from "./build-update-proforma-by-id-params"; -export * from "./focus-first-proforma-update-error"; +export * from "./focus-first-proforma-update-form-error"; diff --git a/modules/customers/src/web/shared/api/update-customer-by-id.api.ts b/modules/customers/src/web/shared/api/update-customer-by-id.api.ts index 4994ed56..14c19749 100644 --- a/modules/customers/src/web/shared/api/update-customer-by-id.api.ts +++ b/modules/customers/src/web/shared/api/update-customer-by-id.api.ts @@ -25,6 +25,7 @@ export function updateCustomerById( const { id, data } = params; if (!id) throw new Error("customerId is required"); + return dataSource.updateOne( "customers", id, 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 c68a5814..3b73665b 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,4 +1,5 @@ import { applyValidationErrorCollection } from "@erp/core"; +import { formHasAnyDirty } from "@erp/core/client"; import { useHookForm } from "@erp/core/hooks"; import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd"; import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; @@ -13,12 +14,9 @@ import { useCustomerUpdateMutation, } from "../../shared"; import { mapCustomerToCustomerUpdateForm } from "../adapters"; +import { type CustomerUpdateForm, CustomerUpdateFormSchema } from "../entities"; import { - type CustomerUpdateForm, - CustomerUpdateFormSchema, - defaultCustomerUpdateForm, -} from "../entities"; -import { + buildCustomerUpdateDefault, buildCustomerUpdatePatch, buildUpdateCustomerByIdParams, focusFirstCustomerUpdateFormError, @@ -68,7 +66,7 @@ export const useCustomerUpdateController = ( } = useCustomerUpdateMutation(); const initialValues = useMemo(() => { - if (!customerData) return defaultCustomerUpdateForm; + if (!customerData) return buildCustomerUpdateDefault(); return mapCustomerToCustomerUpdateForm(customerData); }, [customerData]); @@ -95,7 +93,7 @@ export const useCustomerUpdateController = ( const resetForm = () => { const initialData = customerData ? mapCustomerToCustomerUpdateForm(customerData) - : defaultCustomerUpdateForm; + : buildCustomerUpdateDefault(); form.reset(initialData, { keepDirty: false }); }; @@ -107,6 +105,14 @@ export const useCustomerUpdateController = ( return; } + if (!formHasAnyDirty(form.formState.dirtyFields)) { + showWarningToast( + t("customers.update.no_changes.title"), + t("customers.update.no_changes.message") + ); + return; + } + //const previousData = customerData; const patchData = buildCustomerUpdatePatch(formData, form.formState.dirtyFields); @@ -149,7 +155,7 @@ export const useCustomerUpdateController = ( // 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, + previousData ? mapCustomerToCustomerUpdateForm(previousData) : buildCustomerUpdateDefault(), { keepDirty: false } );*/ @@ -186,6 +192,7 @@ export const useCustomerUpdateController = ( } }, (errors: FieldErrors) => { + console.log(errors); focusFirstCustomerUpdateFormError(form); showWarningToast( @@ -196,7 +203,7 @@ export const useCustomerUpdateController = ( ); // Evento onSubmit ya preparado para el - const onSubmit = (event: React.FormEvent) => { + const onSubmit = (event: React.SubmitEvent) => { event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM submitHandler(event); }; diff --git a/modules/customers/src/web/update/entities/customer-update-form-default.entity.ts b/modules/customers/src/web/update/entities/customer-update-form-default.entity.ts deleted file mode 100644 index 96b5246a..00000000 --- a/modules/customers/src/web/update/entities/customer-update-form-default.entity.ts +++ /dev/null @@ -1,33 +0,0 @@ -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", -}; diff --git a/modules/customers/src/web/update/entities/customer-update-form.entity.ts b/modules/customers/src/web/update/entities/customer-update-form.entity.ts index f232dd73..04f35200 100644 --- a/modules/customers/src/web/update/entities/customer-update-form.entity.ts +++ b/modules/customers/src/web/update/entities/customer-update-form.entity.ts @@ -1,5 +1,5 @@ /** - * CustomerUpdateForm representa el shape de datos del formulario de actualización de cliente. + * CustomerUpdateForm representa los datos del formulario de actualización de cliente. * Es decir, los campos que se muestran en el formulario y que el usuario puede editar. * * Es específico de la UI y no tiene por qué coincidir diff --git a/modules/customers/src/web/update/entities/customer-update-patch.entity.ts b/modules/customers/src/web/update/entities/customer-update-patch.entity.ts index 3a2c02eb..486d6d73 100644 --- a/modules/customers/src/web/update/entities/customer-update-patch.entity.ts +++ b/modules/customers/src/web/update/entities/customer-update-patch.entity.ts @@ -1,5 +1,6 @@ /** - * CustomerUpdatePatch representa los cambios efectivos que se enviarán en update. + * CustomerUpdatePatch representa los cambios que se han hecho + * en el formulario de edición. * * Reglas: * - `undefined` => no enviar @@ -8,7 +9,7 @@ * - mantiene naming camelCase porque sigue siendo contrato frontend */ -export interface CustomerUpdatePatch { +export type CustomerUpdatePatch = { reference?: string | null; isCompany?: boolean; name?: string; @@ -38,4 +39,4 @@ export interface CustomerUpdatePatch { languageCode?: string; currencyCode?: string; -} +}; diff --git a/modules/customers/src/web/update/entities/index.ts b/modules/customers/src/web/update/entities/index.ts index 9e768713..9446e1fb 100644 --- a/modules/customers/src/web/update/entities/index.ts +++ b/modules/customers/src/web/update/entities/index.ts @@ -1,4 +1,3 @@ export * from "./customer-update-form.entity"; export * from "./customer-update-form.schema"; -export * from "./customer-update-form-default.entity"; export * from "./customer-update-patch.entity"; diff --git a/modules/customers/src/web/update/utils/build-customer-update-default.ts b/modules/customers/src/web/update/utils/build-customer-update-default.ts new file mode 100644 index 00000000..73695d06 --- /dev/null +++ b/modules/customers/src/web/update/utils/build-customer-update-default.ts @@ -0,0 +1,35 @@ +import type { CustomerUpdateForm } from "../entities"; + +export const buildCustomerUpdateDefault = (): CustomerUpdateForm => { + return { + 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", + }; +}; diff --git a/modules/customers/src/web/update/utils/index.ts b/modules/customers/src/web/update/utils/index.ts index ee307e2a..6e267c0e 100644 --- a/modules/customers/src/web/update/utils/index.ts +++ b/modules/customers/src/web/update/utils/index.ts @@ -1,3 +1,4 @@ export * from "./build-customer.update-patch"; +export * from "./build-customer-update-default"; export * from "./build-update-customer-by-id-params"; export * from "./focus-first-customer-update-form-error"; diff --git a/packages/rdx-ddd/src/helpers/normalizers.ts b/packages/rdx-ddd/src/helpers/normalizers.ts index 7021bdb9..e9f348d4 100644 --- a/packages/rdx-ddd/src/helpers/normalizers.ts +++ b/packages/rdx-ddd/src/helpers/normalizers.ts @@ -7,13 +7,23 @@ import { MoneyValue, Percentage, Quantity } from "../value-objects"; /** * Aplica una transformación a un valor potencialmente nulo/indefinido. * - * Si `input` es `null` o `undefined`, devuelve `null`. - * En caso contrario, aplica `map`. + * Casos: + * - `null` → `null` + * - `undefined` → `null` + * - valor no nulo → `map(value)` + * + * Ejemplos: + * ```ts + * mapNullable(null, String); // null + * mapNullable(undefined, String); // null + * mapNullable(12, String); // "12" + * mapNullable("abc", (value) => value.toUpperCase()); // "ABC" + * ``` * * @typeParam T - Tipo de entrada no nulo. * @typeParam R - Tipo de salida. * @param input - Valor potencialmente nulo o indefinido. - * @param map - Función de transformación. + * @param map - Función de transformación aplicada solo si `input` tiene valor. * @returns Resultado de `map` o `null` si el input es nulo. */ function mapNullable(input: T | null | undefined, map: (value: T) => R): R | null { @@ -23,10 +33,17 @@ function mapNullable(input: T | null | undefined, map: (value: T) => R): R /** * Serializa un `Maybe` a un valor de salida, aplicando una política explícita para `None`. * - * - Si `Maybe` es `None`, devuelve `onNone`. - * - Si es `Some`, aplica `map`. + * Casos: + * - `Maybe.none()` → `onNone` + * - `Maybe.some(value)` → `map(value)` + * - valor falsy inesperado → `onNone` * - * Esta función centraliza la lógica común de serialización de `Maybe`. + * Ejemplos: + * ```ts + * serializeMaybe(Maybe.none(), null, (value) => value); // null + * serializeMaybe(Maybe.none(), "", (value) => value); // "" + * serializeMaybe(Maybe.some("abc"), null, (value) => value.toUpperCase()); // "ABC" + * ``` * * @typeParam T - Tipo interno del Maybe. * @typeParam R - Tipo de salida. @@ -40,18 +57,28 @@ function serializeMaybe(maybe: Maybe, onNone: R, map: (value: T) => R): } /** - * Convierte un valor (normal o `Maybe`) en un valor nullable (`R | null`). + * Convierte un valor normal, nullable o `Maybe` en un valor nullable (`R | null`). * - * Soporta: - * - `null | undefined` → `null` - * - `T` → `map(T)` + * Casos: + * - `null` → `null` + * - `undefined` → `null` + * - valor directo `T` → `map(value)` * - `Maybe.none()` → `null` - * - `Maybe.some(T)` → `map(T)` + * - `Maybe.some(value)` → `map(value)` * - * @typeParam T - Tipo de entrada. + * Ejemplos: + * ```ts + * toNullable(null, String); // null + * toNullable(undefined, String); // null + * toNullable(12, String); // "12" + * toNullable(Maybe.none(), String); // null + * toNullable(Maybe.some(12), String); // "12" + * ``` + * + * @typeParam V - Tipo de entrada. * @typeParam R - Tipo de salida. - * @param input - Valor o `Maybe` a transformar. - * @param map - Función de transformación. + * @param input - Valor directo, nullable o `Maybe` a transformar. + * @param map - Función de transformación aplicada solo cuando hay valor. * @returns Valor transformado o `null`. */ export function toNullable(input: null | undefined, map: (t: V) => R): null; @@ -69,18 +96,29 @@ export function toNullable( } /** - * Convierte un valor nullable en un `Maybe` aplicando validación. + * Convierte un valor nullable o vacío en un `Maybe` aplicando validación. * - * - Si el valor es `null`, `undefined` o vacío → `Maybe.none()` - * - Si tiene valor → aplica `validate` - * - Success → `Maybe.some` - * - Failure → propaga error + * Casos: + * - `null` → `Result.ok(Maybe.none())` + * - `undefined` → `Result.ok(Maybe.none())` + * - `""` → `Result.ok(Maybe.none())` + * - whitespace, si `isNullishOrEmpty` lo considera vacío → `Result.ok(Maybe.none())` + * - valor con contenido + validación correcta → `Result.ok(Maybe.some(value))` + * - valor con contenido + validación fallida → `Result.fail(error)` + * + * Ejemplos: + * ```ts + * maybeFromNullableResult(null, TextValue.create); // Ok(None) + * maybeFromNullableResult("", TextValue.create); // Ok(None) + * maybeFromNullableResult("ACME", TextValue.create); // Ok(Some(TextValue)) + * maybeFromNullableResult("x", TextValue.create); // Fail(error), si el VO lo rechaza + * ``` * * @typeParam T - Tipo validado. * @typeParam S - Tipo de entrada. * @param input - Valor a validar. - * @param validate - Función de validación. - * @returns Resultado con `Maybe`. + * @param validate - Función de validación/construcción del valor cuando `input` tiene contenido. + * @returns Resultado con `Maybe` o error de validación. */ export function maybeFromNullableResult( input: S, @@ -97,17 +135,26 @@ export function maybeFromNullableResult( /** * Serializa un `Maybe` a un objeto aplicando una política de objeto vacío. * - * - `None` → `emptyObject` - * - `Some` → `map(value)` o `defaultSerializer(value)` + * Casos: + * - `Maybe.none()` → `emptyObject` + * - `Maybe.some(value)` sin `map` → `defaultSerializer(value)` + * - `Maybe.some(value)` con `map` → `map(value)` + * + * Ejemplos: + * ```ts + * maybeToEmptyObjectString(Maybe.none(), emptyMoney, serializeMoney); // emptyMoney + * maybeToEmptyObjectString(Maybe.some(money), emptyMoney, serializeMoney); // serializeMoney(money) + * maybeToEmptyObjectString(Maybe.some(money), emptyMoney, serializeMoney, customMap); // customMap(money) + * ``` * * @internal * * @typeParam T - Tipo interno del Maybe. - * @typeParam R - Tipo de salida (objeto de transporte). + * @typeParam R - Tipo de salida, normalmente objeto de transporte. * @param maybe - Instancia `Maybe`. - * @param emptyObject - Objeto vacío a devolver si es `None`. - * @param defaultSerializer - Serializador por defecto. - * @param map - Serializador opcional personalizado. + * @param emptyObject - Objeto a devolver si es `None`. + * @param defaultSerializer - Serializador por defecto para `Some`. + * @param map - Serializador opcional personalizado; prevalece sobre `defaultSerializer`. * @returns Objeto serializado. */ function maybeToEmptyObjectString( @@ -124,11 +171,28 @@ function maybeToEmptyObjectString( /** * Serializa un `Maybe` a un objeto de transporte. * - * - `None` → objeto vacío de dinero - * - `Some` → `map` o `toObjectString()` + * Casos: + * - `Maybe.none()` → `MoneyValue.EMPTY_MONEY_OBJECT` + * - `Maybe.some(value)` sin `map` → `value.toObjectString()` + * - `Maybe.some(value)` con `map` → `map(value)` + * + * Ejemplos: + * ```ts + * maybeToEmptyMoneyObjectString(Maybe.none()); + * // { value: "", scale: "", currency_code: "" } + * + * maybeToEmptyMoneyObjectString(Maybe.some(money)); + * // { value: "1000", scale: "2", currency_code: "EUR" } + * + * maybeToEmptyMoneyObjectString(Maybe.some(money), (value) => ({ + * value: value.toDecimalString(), + * scale: "2", + * currency_code: "EUR", + * })); + * ``` * * @param maybe - Maybe de `MoneyValue`. - * @param map - Transformación opcional. + * @param map - Transformación opcional; si existe, sustituye a `toObjectString()`. * @returns Objeto de dinero serializado. */ export function maybeToEmptyMoneyObjectString( @@ -146,11 +210,27 @@ export function maybeToEmptyMoneyObjectString( /** * Serializa un `Maybe` a un objeto de transporte. * - * - `None` → objeto vacío - * - `Some` → `map` o `toObjectString()` + * Casos: + * - `Maybe.none()` → `Percentage.EMPTY_PERCENTAGE_OBJECT` + * - `Maybe.some(value)` sin `map` → `value.toObjectString()` + * - `Maybe.some(value)` con `map` → `map(value)` + * + * Ejemplos: + * ```ts + * maybeToEmptyPercentageObjectString(Maybe.none()); + * // { value: "", scale: "" } + * + * maybeToEmptyPercentageObjectString(Maybe.some(percentage)); + * // { value: "2100", scale: "2" } + * + * maybeToEmptyPercentageObjectString(Maybe.some(percentage), (value) => ({ + * value: value.toDecimalString(), + * scale: "2", + * })); + * ``` * * @param maybe - Maybe de `Percentage`. - * @param map - Transformación opcional. + * @param map - Transformación opcional; si existe, sustituye a `toObjectString()`. * @returns Objeto de porcentaje serializado. */ export function maybeToEmptyPercentageObjectString( @@ -168,11 +248,27 @@ export function maybeToEmptyPercentageObjectString( /** * Serializa un `Maybe` a un objeto de transporte. * - * - `None` → objeto vacío - * - `Some` → `map` o `toObjectString()` + * Casos: + * - `Maybe.none()` → `Quantity.EMPTY_QUANTITY_OBJECT` + * - `Maybe.some(value)` sin `map` → `value.toObjectString()` + * - `Maybe.some(value)` con `map` → `map(value)` + * + * Ejemplos: + * ```ts + * maybeToEmptyQuantityObjectString(Maybe.none()); + * // { value: "", scale: "" } + * + * maybeToEmptyQuantityObjectString(Maybe.some(quantity)); + * // { value: "1500", scale: "3" } + * + * maybeToEmptyQuantityObjectString(Maybe.some(quantity), (value) => ({ + * value: value.toDecimalString(), + * scale: "3", + * })); + * ``` * * @param maybe - Maybe de `Quantity`. - * @param map - Transformación opcional. + * @param map - Transformación opcional; si existe, sustituye a `toObjectString()`. * @returns Objeto de cantidad serializado. */ export function maybeToEmptyQuantityObjectString( @@ -188,14 +284,26 @@ export function maybeToEmptyQuantityObjectString( } /** - * Convierte un string nullable en `Maybe`. + * Convierte un string nullable o vacío en `Maybe`. * - * - `null | undefined` → `None` - * - `""` o whitespace → `None` - * - valor válido → `Some(trimmed)` + * Casos: + * - `null` → `Maybe.none()` + * - `undefined` → `Maybe.none()` + * - `""` → `Maybe.none()` + * - `" "` → `Maybe.none()` + * - `" abc "` → `Maybe.some("abc")` + * + * Ejemplos: + * ```ts + * maybeFromNullableOrEmptyString(null); // None + * maybeFromNullableOrEmptyString(undefined); // None + * maybeFromNullableOrEmptyString(""); // None + * maybeFromNullableOrEmptyString(" "); // None + * maybeFromNullableOrEmptyString(" ACME "); // Some("ACME") + * ``` * * @param input - String de entrada. - * @returns Maybe del string. + * @returns Maybe del string normalizado con `trim()`. */ export function maybeFromNullableOrEmptyString(input?: string | null): Maybe { if (input == null) return Maybe.none(); @@ -207,14 +315,22 @@ export function maybeFromNullableOrEmptyString(input?: string | null): Maybe` en `R | null`. * - * - `None` → `null` - * - `Some` → `map(value)` + * Casos: + * - `Maybe.none()` → `null` + * - `Maybe.some(value)` → `map(value)` + * + * Ejemplos: + * ```ts + * maybeToNullable(Maybe.none(), String); // null + * maybeToNullable(Maybe.some(12), String); // "12" + * maybeToNullable(Maybe.some("abc"), (value) => value.toUpperCase()); // "ABC" + * ``` * * @typeParam T - Tipo interno. * @typeParam R - Tipo de salida. * @param maybe - Instancia Maybe. - * @param map - Transformación. - * @returns Valor serializado o null. + * @param map - Transformación aplicada solo en caso `Some`. + * @returns Valor serializado o `null`. */ export function maybeToNullable(maybe: Maybe, map: (t: T) => R): R | null { return serializeMaybe(maybe, null, map); @@ -223,12 +339,22 @@ export function maybeToNullable(maybe: Maybe, map: (t: T) => R): R | nu /** * Convierte un `Maybe` en `string | null`. * - * - `None` → `null` - * - `Some` → `map(value)` o `String(value)` + * Casos: + * - `Maybe.none()` → `null` + * - `Maybe.some(value)` sin `map` → `String(value)` + * - `Maybe.some(value)` con `map` → `String(map(value))` * + * Ejemplos: + * ```ts + * maybeToNullableString(Maybe.none()); // null + * maybeToNullableString(Maybe.some(12)); // "12" + * maybeToNullableString(Maybe.some(user), (value) => value.name); // "John" + * ``` + * + * @typeParam T - Tipo interno. * @param maybe - Instancia Maybe. * @param map - Transformación opcional. - * @returns String o null. + * @returns String serializado o `null`. */ export function maybeToNullableString(maybe: Maybe, map?: (t: T) => string): string | null { return serializeMaybe(maybe, null, (value) => (map ? String(map(value)) : String(value))); @@ -237,12 +363,22 @@ export function maybeToNullableString(maybe: Maybe, map?: (t: T) => string /** * Convierte un `Maybe` en `string`. * - * - `None` → `""` - * - `Some` → `map(value)` o `String(value)` + * Casos: + * - `Maybe.none()` → `""` + * - `Maybe.some(value)` sin `map` → `String(value)` + * - `Maybe.some(value)` con `map` → `map(value)` * + * Ejemplos: + * ```ts + * maybeToEmptyString(Maybe.none()); // "" + * maybeToEmptyString(Maybe.some(12)); // "12" + * maybeToEmptyString(Maybe.some(user), (value) => value.name); // "John" + * ``` + * + * @typeParam T - Tipo interno. * @param maybe - Instancia Maybe. * @param map - Transformación opcional. - * @returns String (nunca null). + * @returns String serializado; nunca `null`. */ export function maybeToEmptyString(maybe: Maybe, map?: (t: T) => string): string { return serializeMaybe(maybe, "", (value) => (map ? map(value) : String(value))); diff --git a/packages/rdx-ddd/src/value-objects/tin-number.ts b/packages/rdx-ddd/src/value-objects/tin-number.ts index 16d065f1..7df0f784 100644 --- a/packages/rdx-ddd/src/value-objects/tin-number.ts +++ b/packages/rdx-ddd/src/value-objects/tin-number.ts @@ -1,6 +1,8 @@ import { Result } from "@repo/rdx-utils"; import { z } from "zod/v4"; + import { translateZodValidationError } from "../helpers"; + import { ValueObject } from "./value-object"; interface TINNumberProps { @@ -20,7 +22,8 @@ export class TINNumber extends ValueObject { }) .max(TINNumber.MAX_LENGTH, { message: `TIN must be at most ${TINNumber.MAX_LENGTH} characters long`, - }); + }) + .regex(/^(?=.*[A-Za-z0-9])[A-Za-z0-9.\-/ ]+$/, "TIN has an invalid format."); return schema.safeParse(value); } diff --git a/packages/rdx-ui/src/components/form/checkbox-field.tsx b/packages/rdx-ui/src/components/form/checkbox-field.tsx new file mode 100644 index 00000000..449e36ad --- /dev/null +++ b/packages/rdx-ui/src/components/form/checkbox-field.tsx @@ -0,0 +1,98 @@ +import { + Checkbox, + Field, + FieldContent, + FieldDescription, + FieldError, +} from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import * as React from "react"; +import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form"; + +import { FormFieldLabel } from "./form-field-label.tsx"; + +type CheckboxFieldProps = { + name: FieldPath; + + label: string; + description?: string; + + disabled?: boolean; + required?: boolean; + readOnly?: boolean; + + orientation?: "vertical" | "horizontal" | "responsive"; + + className?: string; + inputClassName?: string; +}; + +export const CheckboxField = ({ + name, + + label, + description, + + disabled = false, + required = false, + readOnly = false, + + orientation = "horizontal", + + className, + inputClassName, +}: CheckboxFieldProps) => { + const { control, formState } = useFormContext(); + + const inputId = React.useId(); + const descriptionId = description ? `${inputId}-description` : undefined; + + const isDisabled = disabled || readOnly || formState.isSubmitting; + + return ( + { + const hasError = Boolean(fieldState.error); + + return ( + + { + field.onChange(checked === true); + }} + ref={field.ref} + required={required} + /> + + + + {label} + + + {description ? ( + {description} + ) : null} + + + + + ); + }} + /> + ); +}; diff --git a/packages/rdx-ui/src/components/form/index.ts b/packages/rdx-ui/src/components/form/index.ts index aa45a4f9..38b62557 100644 --- a/packages/rdx-ui/src/components/form/index.ts +++ b/packages/rdx-ui/src/components/form/index.ts @@ -1,3 +1,4 @@ +export * from "./checkbox-field.tsx"; export * from "./date-picker-field.tsx"; export * from "./date-picker-input-field/index.ts"; export * from "./decimal-field/index.ts"; diff --git a/packages/rdx-utils/src/helpers/index.ts b/packages/rdx-utils/src/helpers/index.ts index d390b0bb..921e50bf 100644 --- a/packages/rdx-utils/src/helpers/index.ts +++ b/packages/rdx-utils/src/helpers/index.ts @@ -1,6 +1,7 @@ export * from "./collection"; export * from "./id-utils"; export * from "./maybe"; +export * from "./object-helper"; export * from "./patch-field"; export * from "./result"; export * from "./result-collection"; diff --git a/packages/rdx-utils/src/helpers/object-helper.ts b/packages/rdx-utils/src/helpers/object-helper.ts new file mode 100644 index 00000000..a14ea687 --- /dev/null +++ b/packages/rdx-utils/src/helpers/object-helper.ts @@ -0,0 +1,30 @@ +/** + * Comprueba si un objeto posee una propiedad propia (no heredada) con la clave especificada. + * + * Esta función actúa como un wrapper tipado sobre `Object.hasOwn`, garantizando + * seguridad de tipos al restringir la clave (`key`) a las propiedades definidas + * en el tipo del objeto (`T`). + * + * @typeParam T - Tipo del objeto sobre el cual se realizará la comprobación. + * + * @param obj - Objeto en el que se desea verificar la existencia de la propiedad. + * @param key - Clave de la propiedad a comprobar; debe ser una clave válida de `T`. + * + * @returns `true` si el objeto tiene la propiedad como propia (no en su prototipo), + * `false` en caso contrario. + * + * @example + * ```ts + * const user = { name: "Alice", age: 30 }; + * + * hasOwn(user, "name"); // true + * hasOwn(user, "toString"); // false + * ``` + */ +export const hasOwn = (obj: T, key: keyof T): boolean => { + return Object.hasOwn(obj, key); +}; + +export const ObjectHelper = { + hasOwn, +}; diff --git a/packages/rdx-utils/src/helpers/rule-validator.ts b/packages/rdx-utils/src/helpers/rule-validator.ts index 6f3caf6a..dc004e3e 100644 --- a/packages/rdx-utils/src/helpers/rule-validator.ts +++ b/packages/rdx-utils/src/helpers/rule-validator.ts @@ -1,4 +1,5 @@ -import Joi, { ValidationError } from "joi"; +import Joi, { type ValidationError } from "joi"; + import { Result } from "./result"; export type TRuleValidatorResult = Result; diff --git a/packages/shadcn-ui/tailwind.config.mts b/packages/shadcn-ui/tailwind.config.mts index 71555ad9..b409701e 100644 --- a/packages/shadcn-ui/tailwind.config.mts +++ b/packages/shadcn-ui/tailwind.config.mts @@ -6,11 +6,12 @@ const config = { "apps/**/*.{ts,tsx}", "../../packages/shadcn-ui/src/**/*.{ts,tsx}", "../../modules/**/*.{ts,tsx}", - ], + ], theme: { extend: {}, }, plugins: [], + }; export default config;