Edición de proformas y más

This commit is contained in:
David Arranz 2026-04-28 11:34:22 +02:00
parent c9ba2d0370
commit cfbc4e6657
48 changed files with 947 additions and 221 deletions

View File

@ -59,7 +59,7 @@ export const App = () => {
<RouterProvider router={appRouter} /> <RouterProvider router={appRouter} />
</Suspense> </Suspense>
</TooltipProvider> </TooltipProvider>
<Toaster expand={true} position="bottom-center" /> <Toaster expand={true} position="top-center" richColors />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />} {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</AuthProvider> </AuthProvider>
</DataSourceProvider> </DataSourceProvider>

View File

@ -16,8 +16,8 @@ import { z } from "zod/v4";
export const TinSchema = z export const TinSchema = z
.string() .string()
.trim() .trim()
.min(1, "TIN cannot be empty.") .min(2, "TIN cannot be empty.")
.max(32, "TIN is too long.") .max(10, "TIN is too long.")
.regex(/^(?=.*[A-Za-z0-9])[A-Za-z0-9.\-/ ]+$/, "TIN has an invalid format."); .regex(/^(?=.*[A-Za-z0-9])[A-Za-z0-9.\-/ ]+$/, "TIN has an invalid format.");
export type TinDTO = z.infer<typeof TinSchema>; export type TinDTO = z.infer<typeof TinSchema>;

View File

@ -70,8 +70,15 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
data: TData, data: TData,
params?: Record<string, unknown> params?: Record<string, unknown>
): Promise<R> => { ): Promise<R> => {
const url = `${resource}/${id}`;
console.log("Axios updateOne => ", {
url,
data,
});
const res = await client.put<R, AxiosResponse<R>, TData>( const res = await client.put<R, AxiosResponse<R>, TData>(
`${resource}/${id}`, url,
data, data,
params as AxiosRequestConfig<TData> params as AxiosRequestConfig<TData>
); );

View File

@ -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 = <T>(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<TInput, TOutput> = (value: TInput) => TOutput;
export type DirtyPatchSpec<TForm extends object, TPatch extends object> = {
[K in keyof TForm & keyof TPatch]?: FieldNormalizer<TForm[K], TPatch[K]>;
};
/**
* Comprueba si un campo top-level fue modificado.
*
* Se encapsula el cast porque `FieldNamesMarkedBoolean<T>` puede representar
* estructuras anidadas, pero este builder solo soporta claves top-level.
*/
const isTopLevelDirty = <TForm extends object>(
dirtyFields: FieldNamesMarkedBoolean<TForm>,
key: keyof TForm
): boolean => {
return Boolean((dirtyFields as Partial<Record<keyof TForm, unknown>>)[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<Form, Patch>;
*
* const patch = buildTopLevelDirtyPatch(formData, dirtyFields, patchSpec);
* ```
*/
export const buildTopLevelFormDirtyPatch = <TForm extends object, TPatch extends object>(
formData: TForm,
dirtyFields: FieldNamesMarkedBoolean<TForm>,
spec: DirtyPatchSpec<TForm, TPatch>
): TPatch => {
const patch: Partial<TPatch> = {};
for (const key of Object.keys(spec) as Array<keyof TForm & keyof TPatch>) {
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;
};

View File

@ -1,2 +1,3 @@
export * from "./build-top-level-form-dirty-patch";
export * from "./form-utils"; export * from "./form-utils";
export * from "./http-url-utils"; export * from "./http-url-utils";

View File

@ -1,4 +1,4 @@
import { IModuleClient, ModuleClientParams } from "./lib"; import type { IModuleClient, ModuleClientParams } from "./lib";
export const MODULE_NAME = "Core"; export const MODULE_NAME = "Core";
const MODULE_VERSION = "1.0.0"; const MODULE_VERSION = "1.0.0";

View File

@ -50,6 +50,8 @@ export class ProformaUpdater implements IProformaUpdater {
return Result.fail(updateResult.error); return Result.fail(updateResult.error);
} }
console.log(proforma);
// Persistir cambios // Persistir cambios
const saveResult = await this.repository.update(proforma, transaction); const saveResult = await this.repository.update(proforma, transaction);

View File

@ -16,23 +16,19 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder
private readonly itemsBuilder: IProformaItemsFullSnapshotBuilder, private readonly itemsBuilder: IProformaItemsFullSnapshotBuilder,
private readonly recipientBuilder: IProformaRecipientFullSnapshotBuilder, private readonly recipientBuilder: IProformaRecipientFullSnapshotBuilder,
private readonly taxesBuilder: IProformaTaxesFullSnapshotBuilder private readonly taxesBuilder: IProformaTaxesFullSnapshotBuilder
//private readonly paymentMethodBuilder: IProformaPaymentMethodFullSnapshotBuilder
) {} ) {}
toOutput(proforma: Proforma): GetProformaByIdResponseDTO { toOutput(proforma: Proforma): GetProformaByIdResponseDTO {
const items = this.itemsBuilder.toOutput(proforma.items); const items = this.itemsBuilder.toOutput(proforma.items);
const recipient = this.recipientBuilder.toOutput(proforma); const recipient = this.recipientBuilder.toOutput(proforma);
const taxes = this.taxesBuilder.toOutput(proforma.taxes()); const taxes = this.taxesBuilder.toOutput(proforma.taxes());
//const paymentMethod = this.paymentMethodBuilder.toOutput(proforma.paymentMethod);
const payment = proforma.paymentMethod.match( const paymentMethod = maybeToNullable(proforma.paymentMethodId, (value) => ({
(payment) => { id: value.toString(),
const { id, payment_description } = payment.toObjectString(); description: "",
return { }));
id: id,
description: payment_description,
};
},
() => null
);
const allTotals = proforma.totals(); const allTotals = proforma.totals();
@ -59,7 +55,7 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder
linked_invoice_id: maybeToNullable(proforma.linkedInvoiceId, (value) => value.toString()), linked_invoice_id: maybeToNullable(proforma.linkedInvoiceId, (value) => value.toString()),
payment_method: payment, payment_method: paymentMethod,
subtotal_amount: allTotals.subtotalAmount.toObjectString(), subtotal_amount: allTotals.subtotalAmount.toObjectString(),
items_discount_amount: allTotals.itemsDiscountAmount.toObjectString(), items_discount_amount: allTotals.itemsDiscountAmount.toObjectString(),

View File

@ -1,9 +1,7 @@
import type { ISnapshotBuilder } from "@erp/core/api"; import type { ISnapshotBuilder } from "@erp/core/api";
import type { ProformaItemDetailDTO } from "@erp/customer-invoices/common"; import type { ProformaItemDetailDTO } from "@erp/customer-invoices/common";
import { import {
maybeToEmptyMoneyObjectString,
maybeToEmptyPercentageObjectString, maybeToEmptyPercentageObjectString,
maybeToEmptyQuantityObjectString,
maybeToEmptyString, maybeToEmptyString,
maybeToNullable, maybeToNullable,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
@ -17,6 +15,7 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps
private mapItem(proformaItem: ProformaItem, index: number): ProformaItemDetailDTO { private mapItem(proformaItem: ProformaItem, index: number): ProformaItemDetailDTO {
const allAmounts = proformaItem.totals(); const allAmounts = proformaItem.totals();
const isValued = proformaItem.isValued(); const isValued = proformaItem.isValued();
const currencyCode = proformaItem.currencyCode.code;
return { return {
id: proformaItem.id.toPrimitive(), id: proformaItem.id.toPrimitive(),
@ -25,54 +24,58 @@ export class ProformaItemsFullSnapshotBuilder implements IProformaItemsFullSnaps
description: maybeToNullable(proformaItem.description, (value) => value.toString()), description: maybeToNullable(proformaItem.description, (value) => value.toString()),
quantity: maybeToEmptyQuantityObjectString(proformaItem.quantity), quantity: maybeToNullable(proformaItem.quantity, (value) => value.toObjectString()),
unit_amount: maybeToEmptyMoneyObjectString(proformaItem.unitAmount), unit_amount: maybeToNullable(proformaItem.unitAmount, (value) => value.toObjectString()),
subtotal_amount: isValued subtotal_amount: isValued
? allAmounts.subtotalAmount.toObjectString() ? allAmounts.subtotalAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT, : ItemAmount.zero(currencyCode).toObjectString(),
item_discount_percentage: maybeToEmptyPercentageObjectString( item_discount_percentage: maybeToNullable(proformaItem.itemDiscountPercentage, (value) =>
proformaItem.itemDiscountPercentage value.toObjectString()
), ),
item_discount_amount: isValued item_discount_amount: isValued
? allAmounts.itemDiscountAmount.toObjectString() ? allAmounts.itemDiscountAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT, : ItemAmount.zero(currencyCode).toObjectString(),
global_discount_percentage: proformaItem.globalDiscountPercentage.toObjectString(), global_discount_percentage: proformaItem.globalDiscountPercentage.toObjectString(),
global_discount_amount: isValued global_discount_amount: isValued
? allAmounts.globalDiscountAmount.toObjectString() ? allAmounts.globalDiscountAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT, : ItemAmount.zero(currencyCode).toObjectString(),
total_discount_amount: isValued total_discount_amount: isValued
? allAmounts.totalDiscountAmount.toObjectString() ? allAmounts.totalDiscountAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT, : ItemAmount.zero(currencyCode).toObjectString(),
taxable_amount: isValued taxable_amount: isValued
? allAmounts.taxableAmount.toObjectString() ? allAmounts.taxableAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT, : ItemAmount.zero(currencyCode).toObjectString(),
iva_code: maybeToEmptyString(proformaItem.ivaCode()), iva_code: maybeToEmptyString(proformaItem.ivaCode()),
iva_percentage: maybeToEmptyPercentageObjectString(proformaItem.ivaPercentage()), 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_code: maybeToEmptyString(proformaItem.recCode()),
rec_percentage: maybeToEmptyPercentageObjectString(proformaItem.recPercentage()), 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_code: maybeToEmptyString(proformaItem.retentionCode()),
retention_percentage: maybeToEmptyPercentageObjectString(proformaItem.retentionPercentage()), retention_percentage: maybeToEmptyPercentageObjectString(proformaItem.retentionPercentage()),
retention_amount: isValued retention_amount: isValued
? allAmounts.retentionAmount.toObjectString() ? allAmounts.retentionAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT, : ItemAmount.zero(currencyCode).toObjectString(),
taxes_amount: isValued taxes_amount: isValued
? allAmounts.taxesAmount.toObjectString() ? allAmounts.taxesAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT, : ItemAmount.zero(currencyCode).toObjectString(),
total_amount: isValued total_amount: isValued
? allAmounts.totalAmount.toObjectString() ? allAmounts.totalAmount.toObjectString()
: ItemAmount.EMPTY_MONEY_OBJECT, : ItemAmount.zero(currencyCode).toObjectString(),
}; };
} }

View File

@ -151,7 +151,9 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return Result.ok(proforma); return Result.ok(proforma);
} }
private static validateCreateProps(props: IProformaCreateProps): Result<void, Error> { private static validateCreateProps(
props: IProformaCreateProps | ProformaInternalProps
): Result<void, Error> {
return Result.ok(); return Result.ok();
} }
@ -169,7 +171,14 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
...otherProps, ...otherProps,
}; };
console.log(candidateProps);
// Validacciones // Validacciones
const validationResult = Proforma.validateCreateProps(candidateProps);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
// Aplicar cambios // Aplicar cambios
Object.assign(this.props, candidateProps); Object.assign(this.props, candidateProps);

View File

@ -10,12 +10,12 @@ export const ProformaItemDetailSchema = z.object({
position: ItemPositionSchema, position: ItemPositionSchema,
description: z.string().nullable(), description: z.string().nullable(),
quantity: QuantitySchema, quantity: QuantitySchema.nullable(),
unit_amount: MoneySchema, unit_amount: MoneySchema.nullable(),
subtotal_amount: MoneySchema, subtotal_amount: MoneySchema,
item_discount_percentage: PercentageSchema, item_discount_percentage: PercentageSchema.nullable(),
item_discount_amount: MoneySchema, item_discount_amount: MoneySchema,
global_discount_percentage: PercentageSchema, global_discount_percentage: PercentageSchema,

View File

@ -24,7 +24,6 @@ export const GetProformaByIdAdapter = {
return { return {
id: dto.id, id: dto.id,
companyId: dto.company_id, companyId: dto.company_id,
isProforma: dto.is_proforma === "1",
invoiceNumber: dto.invoice_number, invoiceNumber: dto.invoice_number,
status: dto.status as ProformaStatus, status: dto.status as ProformaStatus,
@ -69,6 +68,8 @@ const mapItem = (dto: GetProformaByIdResponseDTO["items"][number]): ProformaItem
return { return {
id: dto.id, id: dto.id,
position: Number(dto.position), position: Number(dto.position),
isValued: dto.is_valued,
description: dto.description, description: dto.description,
quantity: QuantityDTOHelper.toNumber(dto.quantity), quantity: QuantityDTOHelper.toNumber(dto.quantity),

View File

@ -7,6 +7,8 @@
export interface ProformaItem { export interface ProformaItem {
id: string; id: string;
position: number; position: number;
isValued: boolean;
description: string; description: string;
quantity: number; quantity: number;

View File

@ -33,7 +33,12 @@ export const useProformaUpdateMutation = () => {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
} }
console.log("Hola!!!");
const dto: UpdateProformaByIdResult = await updateProformaById(dataSource, params); const dto: UpdateProformaByIdResult = await updateProformaById(dataSource, params);
console.log(dto);
return GetProformaByIdAdapter.fromDto(dto); return GetProformaByIdAdapter.fromDto(dto);
}, },
onSuccess: async (proforma) => { onSuccess: async (proforma) => {

View File

@ -2,7 +2,7 @@ import type { ProformaItem } from "../../shared";
import type { ProformaItemUpdateForm } from "../entities"; 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 * @param proforma
* @returns * @returns
@ -14,6 +14,7 @@ export const mapProformaItemsToProformaItemsUpdateForm = (
return { return {
id: item.id, id: item.id,
position: item.position, position: item.position,
isValued: item.isValued,
description: item.description, description: item.description,
quantity: item.quantity, quantity: item.quantity,
unitAmount: item.unitAmount, unitAmount: item.unitAmount,

View File

@ -4,7 +4,7 @@ import type { ProformaUpdateForm } from "../entities";
import { mapProformaItemsToProformaItemsUpdateForm } from "./proforma-items-to-proforma-items-update-form.adapter"; 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 * @param proforma
* @returns * @returns

View File

@ -2,6 +2,13 @@ import type { CustomerSelectionOption } from "@erp/customers/common";
import type { Proforma } from "../../shared/entities"; 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 = ( export const mapProformaToSelectedCustomer = (
proforma?: Proforma | null proforma?: Proforma | null
): CustomerSelectionOption | null => { ): CustomerSelectionOption | null => {

View File

@ -1,6 +1,8 @@
import { applyValidationErrorCollection } from "@erp/core";
import { formHasAnyDirty } from "@erp/core/client"; import { formHasAnyDirty } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks"; import { useHookForm } from "@erp/core/hooks";
import type { CustomerSelectionOption } from "@erp/customers"; import type { CustomerSelectionOption } from "@erp/customers";
import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { useEffect, useId, useMemo, useState } from "react"; import { useEffect, useId, useMemo, useState } from "react";
import type { FieldErrors } from "react-hook-form"; import type { FieldErrors } from "react-hook-form";
@ -15,7 +17,7 @@ import {
buildProformaUpdateDefault, buildProformaUpdateDefault,
buildProformaUpdatePatch, buildProformaUpdatePatch,
buildUpdateProformaByIdParams, buildUpdateProformaByIdParams,
focusFirstProformaUpdateError, focusFirstProformaUpdateFormError,
} from "../utils"; } from "../utils";
import { useUpdateProformaItemsController } from "./use-update-proforma-items-controller"; import { useUpdateProformaItemsController } from "./use-update-proforma-items-controller";
@ -28,6 +30,18 @@ export interface UseUpdateProformaControllerOptions {
errorToasts?: boolean; 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 = ( export const useUpdateProformaController = (
proformaId?: string, proformaId?: string,
options?: UseUpdateProformaControllerOptions options?: UseUpdateProformaControllerOptions
@ -66,6 +80,7 @@ export const useUpdateProformaController = (
const [selectedCustomer, setSelectedCustomer] = useState<CustomerSelectionOption | null>(null); const [selectedCustomer, setSelectedCustomer] = useState<CustomerSelectionOption | null>(null);
/** Reiniciar el form al recibir datos */
useEffect(() => { useEffect(() => {
if (!proformaData) return; if (!proformaData) return;
@ -115,8 +130,6 @@ export const useUpdateProformaController = (
return; return;
} }
console.log(form.formState.dirtyFields);
if (!formHasAnyDirty(form.formState.dirtyFields)) { if (!formHasAnyDirty(form.formState.dirtyFields)) {
showWarningToast( showWarningToast(
t("proformas.update.no_changes.title"), t("proformas.update.no_changes.title"),
@ -125,11 +138,24 @@ export const useUpdateProformaController = (
return; return;
} }
const previousData = proformaData; //const previousData = proformaData;
const patchData = buildProformaUpdatePatch(formData, form.formState.dirtyFields); 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); const params = buildUpdateProformaByIdParams(proformaId, patchData);
console.log("Enviando actualización con params:", params);
try { try {
// Enviamos cambios al servidor // Enviamos cambios al servidor
const updated = await mutateAsync(params); const updated = await mutateAsync(params);
@ -151,38 +177,59 @@ export const useUpdateProformaController = (
options?.onUpdated?.(updated); options?.onUpdated?.(updated);
} catch (error: unknown) { } catch (error: unknown) {
const normalizedError = const normalizedError = normalizeSubmitError(error);
error instanceof Error ? error : new Error(t("pages.update.error.unknown"));
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 previousData
? mapProformaToProformaUpdateForm(previousData) ? mapProformaToProformaUpdateForm(previousData)
: buildProformaUpdateDefault(), : buildProformaUpdateDefault(),
{ keepDirty: false } { keepDirty: false }
);
setSelectedCustomer(previousData ? mapProformaToSelectedCustomer(previousData) : null); 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) { if (options?.errorToasts !== false) {
showErrorToast(t("proformas.update.error.title"), normalizedError.message); showErrorToast(t("proformas.update.error.title"), normalizedError.message);
} }
options?.onError?.(normalizedError, params); options?.onError?.(normalizedError, params);
} }
}, },
(errors: FieldErrors<ProformaUpdateForm>) => { (errors: FieldErrors<ProformaUpdateForm>) => {
console.log(errors); console.log(errors);
focusFirstProformaUpdateError(errors); focusFirstProformaUpdateFormError(form);
showWarningToast( showWarningToast(
t("proformas.update.validation.title"), t("forms.validation.title", "Revisa los campos"),
t("proformas.update.validation.message") t("forms.validation.message", "Hay errores de validación en el formulario.")
); );
} }
); );
// Evento onSubmit ya preparado para el <form> // Evento onSubmit ya preparado para el <form>
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => { const onSubmit = (event: React.SubmitEvent<HTMLFormElement>) => {
event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
submitHandler(event); submitHandler(event);
}; };

View File

@ -1,8 +1,12 @@
export interface ProformaItemUpdateForm { export interface ProformaItemUpdateForm {
id: string; id: string;
position: number; position: number;
isValued: boolean;
description: string; description: string;
quantity: number | null; quantity: number | null;
unitAmount: number | null; unitAmount: number | null;
itemDiscountPercentage: number | null; itemDiscountPercentage: number | null;
} }

View File

@ -17,10 +17,13 @@ import { z } from "zod/v4";
export const ProformaItemUpdateFormSchema = z.object({ export const ProformaItemUpdateFormSchema = z.object({
id: z.uuid(), id: z.uuid(),
position: z.number().int().nonnegative(), position: z.number().int().nonnegative(),
isValued: z.boolean(),
description: z.string().trim(), description: z.string(),
quantity: z.number().positive().nullable(),
quantity: z.number().nullable(),
unitAmount: z.number().nonnegative().nullable(), unitAmount: z.number().nonnegative().nullable(),
itemDiscountPercentage: z.number().min(0).max(100).nullable(), itemDiscountPercentage: z.number().min(0).max(100).nullable(),
}); });

View File

@ -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 { export interface ProformaItemUpdatePatch {
id: string; id: string;
position: number; position: number;
description: string; isValued: boolean;
description: string | null;
quantity: number | null; quantity: number | null;
unitAmount: number | null; unitAmount: number | null;
itemDiscountPercentage: number | null; itemDiscountPercentage: number | null;
} }

View File

@ -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 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 * Es específico de la UI y no tiene por qué coincidir

View File

@ -1,3 +1,4 @@
import { CurrencyCodeSchema, LanguageCodeSchema } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema"; 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({ export const ProformaUpdateFormSchema = z.object({
series: z.string().default(""), series: z.string().or(z.literal("")),
invoiceDate: z.string().default(""), invoiceDate: z.string(),
operationDate: z.string().default(""), operationDate: z.string().or(z.literal("")),
customerId: z.string().default(""), customerId: z.string(),
description: z.string().default(""), description: z.string(),
reference: z.string().default(""), reference: z.string().or(z.literal("")),
notes: z.string().default(""), notes: z.string().or(z.literal("")),
languageCode: z.string().min(1, "Debe indicar un idioma").default("es"), languageCode: LanguageCodeSchema,
currencyCode: z.string().min(1, "Debe indicar una moneda").default("EUR"), currencyCode: CurrencyCodeSchema,
globalDiscountPercentage: z.number().default(0), globalDiscountPercentage: z.number(),
paymentMethod: z.string().default(""), paymentMethod: z.string().or(z.literal("")),
items: z.array(ProformaItemUpdateFormSchema), items: z.array(ProformaItemUpdateFormSchema),
}); });

View File

@ -1,17 +1,34 @@
import type { ProformaUpdateForm } from "./proforma-update-form.entity";
/** /**
* ProformaUpdatePatch representa los cambios que se van a aplicar a una proforma. * ProformaUpdatePatch representa los cambios que se han hecho
* Se representa con las mismas propiedades que ProformaUpdateForm, * en el formulario de edición y que se aplicarán a la proforma.
* pero todas ellas son opcionales.
*
* A la API solo hay que enviar los campos que han cambiado.
* *
* Reglas: * Reglas:
* - debe ser un Partial de ProformaUpdateForm * - `undefined` => no enviar
* - no debe tener campos adicionales ni transformaciones * - `null` => borrar explícitamente
* - debe ser un shape orientado a la API, no a la UI ni al dominio * - `""` no se usa aquí para campos anulables; el builder lo convierte a null
* - sin shape DTO, solo tipos simples y directos * - mantiene naming camelCase porque sigue siendo contrato frontend
*/ */
export type ProformaUpdatePatch = Partial<ProformaUpdateForm>; 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[];
};

View File

@ -3,6 +3,7 @@ import {
FormSectionCard, FormSectionCard,
FormSectionGrid, FormSectionGrid,
SelectField, SelectField,
TextAreaField,
TextField, TextField,
} from "@repo/rdx-ui/components"; } from "@repo/rdx-ui/components";
@ -72,6 +73,17 @@ export const ProformaUpdateHeaderEditor = ({
placeholder={t("form_fields.proformas.description.placeholder")} placeholder={t("form_fields.proformas.description.placeholder")}
readOnly={readOnly} readOnly={readOnly}
/> />
<TextAreaField
className="md:col-span-12"
disabled={disabled}
label={t("form_fields.proformas.notes.label")}
maxLength={256}
name="notes"
placeholder={t("form_fields.proformas.notes.placeholder")}
readOnly={readOnly}
/>
</FormSectionGrid> </FormSectionGrid>
</FormSectionCard> </FormSectionCard>
); );

View File

@ -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 { Button, Card, CardContent } from "@repo/shadcn-ui/components";
import { CopyIcon, MoveDownIcon, MoveUpIcon, Trash2Icon } from "lucide-react"; import { CopyIcon, MoveDownIcon, MoveUpIcon, Trash2Icon } from "lucide-react";
@ -34,6 +40,12 @@ export const ProformaUpdateItemRowEditor = ({
<Card> <Card>
<CardContent className="space-y-4 pt-6"> <CardContent className="space-y-4 pt-6">
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-4">
<CheckboxField
description={t("customers.fields.is_valued.description")}
label={t("customers.fields.is_valued.label")}
name={`items.${index}.isValued`}
/>
<TextField <TextField
className="col-span-12" className="col-span-12"
label={t("form_fields.items.description.label", "Descripción")} label={t("form_fields.items.description.label", "Descripción")}

View File

@ -6,7 +6,10 @@ export const buildProformaItemUpdateDefault = (position: number): ProformaItemUp
return { return {
id: UniqueID.generateNewID().toString(), id: UniqueID.generateNewID().toString(),
position, position,
isValued: false,
description: "", description: "",
quantity: null, quantity: null,
unitAmount: null, unitAmount: null,
itemDiscountPercentage: null, itemDiscountPercentage: null,

View File

@ -3,6 +3,19 @@ import type { FieldNamesMarkedBoolean } from "react-hook-form";
import type { ProformaUpdateForm, ProformaUpdatePatch } from "../entities"; import type { ProformaUpdateForm, ProformaUpdatePatch } from "../entities";
/**
* Construye el patch de actualización de proforma a partir
* de los campos dirty del formulario.
*
* Reglas:
* - Solo incluye campos modificados.
* - Los objetos anidados se procesan recursivamente.
* - `items` se envía completo si cualquier campo interno ha cambiado.
*
* Importante:
* - No normaliza valores.
*/
export const buildProformaUpdatePatch = ( export const buildProformaUpdatePatch = (
formData: ProformaUpdateForm, formData: ProformaUpdateForm,
dirtyFields: FieldNamesMarkedBoolean<ProformaUpdateForm> dirtyFields: FieldNamesMarkedBoolean<ProformaUpdateForm>

View File

@ -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 { 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 = ( export const buildUpdateProformaByIdParams = (
id: string, id: string,
@ -9,38 +27,86 @@ export const buildUpdateProformaByIdParams = (
throw new Error("proformaId is required"); throw new Error("proformaId is required");
} }
const data: UpdateProformaByIdParams["data"] = { const data: UpdateProformaByIdParams["data"] = {};
series: patch.series,
invoice_date: patch.invoiceDate, if (ObjectHelper.hasOwn(patch, "series")) {
operation_date: patch.operationDate, data.series = patch.series;
}
customer_id: patch.customerId, if (ObjectHelper.hasOwn(patch, "invoiceDate")) {
data.invoice_date = patch.invoiceDate;
}
reference: patch.reference, if (ObjectHelper.hasOwn(patch, "operationDate")) {
description: patch.description, data.operation_date = patch.operationDate;
notes: patch.notes, }
language_code: patch.languageCode, if (ObjectHelper.hasOwn(patch, "customerId")) {
currency_code: patch.currencyCode, data.customer_id = patch.customerId;
}
items: patch.items?.map((item) => ({ if (ObjectHelper.hasOwn(patch, "reference")) {
id: item.id, data.reference = patch.reference;
position: String(item.position), }
description: item.description,
quantity: item.quantity === null ? undefined : String(item.quantity), if (ObjectHelper.hasOwn(patch, "description")) {
unit_amount: item.unitAmount === null ? undefined : String(item.unitAmount), data.description = patch.description;
item_discount_percentage: }
item.itemDiscountPercentage === null
? undefined if (ObjectHelper.hasOwn(patch, "notes")) {
: { value: String(item.itemDiscountPercentage), scale: "2" }, 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 { return {
id, id,
data: Object.fromEntries( data,
Object.entries(data).filter(([, value]) => value !== undefined) } satisfies UpdateProformaByIdParams;
) satisfies UpdateProformaByIdParams["data"], };
const toProformaItemUpdateDTO = (
item: ProformaItemUpdatePatch
): NonNullable<UpdateProformaByIdParams["data"]["items"]>[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: "#;#;#",
}; };
}; };

View File

@ -1,13 +0,0 @@
import type { FieldErrors } from "react-hook-form";
import type { ProformaUpdateForm } from "../entities";
export const focusFirstProformaUpdateError = (errors: FieldErrors<ProformaUpdateForm>) => {
const firstKey = Object.keys(errors)[0] as keyof ProformaUpdateForm | undefined;
if (!firstKey) {
return;
}
document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`)?.focus();
};

View File

@ -0,0 +1,14 @@
import type { UseFormReturn } from "react-hook-form";
import type { ProformaUpdateForm } from "../entities";
export const focusFirstProformaUpdateFormError = (form: UseFormReturn<ProformaUpdateForm>) => {
const errors = form.formState.errors;
const firstKey = Object.keys(errors)[0] as keyof ProformaUpdateForm | undefined;
if (firstKey) {
form.setFocus(firstKey);
}
return;
};

View File

@ -1,5 +1,6 @@
export * from "./build-proforma-item-update-default"; export * from "./build-proforma-item-update-default";
export * from "./build-proforma-update-default"; export * from "./build-proforma-update-default";
export * from "./build-proforma-update-default";
export * from "./build-proforma-update-patch"; export * from "./build-proforma-update-patch";
export * from "./build-update-proforma-by-id-params"; export * from "./build-update-proforma-by-id-params";
export * from "./focus-first-proforma-update-error"; export * from "./focus-first-proforma-update-form-error";

View File

@ -25,6 +25,7 @@ export function updateCustomerById(
const { id, data } = params; const { id, data } = params;
if (!id) throw new Error("customerId is required"); if (!id) throw new Error("customerId is required");
return dataSource.updateOne<UpdateCustomerByIdRequestDTO, UpdateCustomerByIdResponseDTO>( return dataSource.updateOne<UpdateCustomerByIdRequestDTO, UpdateCustomerByIdResponseDTO>(
"customers", "customers",
id, id,

View File

@ -1,4 +1,5 @@
import { applyValidationErrorCollection } from "@erp/core"; import { applyValidationErrorCollection } from "@erp/core";
import { formHasAnyDirty } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks"; import { useHookForm } from "@erp/core/hooks";
import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd"; import { type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
@ -13,12 +14,9 @@ import {
useCustomerUpdateMutation, useCustomerUpdateMutation,
} from "../../shared"; } from "../../shared";
import { mapCustomerToCustomerUpdateForm } from "../adapters"; import { mapCustomerToCustomerUpdateForm } from "../adapters";
import { type CustomerUpdateForm, CustomerUpdateFormSchema } from "../entities";
import { import {
type CustomerUpdateForm, buildCustomerUpdateDefault,
CustomerUpdateFormSchema,
defaultCustomerUpdateForm,
} from "../entities";
import {
buildCustomerUpdatePatch, buildCustomerUpdatePatch,
buildUpdateCustomerByIdParams, buildUpdateCustomerByIdParams,
focusFirstCustomerUpdateFormError, focusFirstCustomerUpdateFormError,
@ -68,7 +66,7 @@ export const useCustomerUpdateController = (
} = useCustomerUpdateMutation(); } = useCustomerUpdateMutation();
const initialValues = useMemo<CustomerUpdateForm>(() => { const initialValues = useMemo<CustomerUpdateForm>(() => {
if (!customerData) return defaultCustomerUpdateForm; if (!customerData) return buildCustomerUpdateDefault();
return mapCustomerToCustomerUpdateForm(customerData); return mapCustomerToCustomerUpdateForm(customerData);
}, [customerData]); }, [customerData]);
@ -95,7 +93,7 @@ export const useCustomerUpdateController = (
const resetForm = () => { const resetForm = () => {
const initialData = customerData const initialData = customerData
? mapCustomerToCustomerUpdateForm(customerData) ? mapCustomerToCustomerUpdateForm(customerData)
: defaultCustomerUpdateForm; : buildCustomerUpdateDefault();
form.reset(initialData, { keepDirty: false }); form.reset(initialData, { keepDirty: false });
}; };
@ -107,6 +105,14 @@ export const useCustomerUpdateController = (
return; return;
} }
if (!formHasAnyDirty(form.formState.dirtyFields)) {
showWarningToast(
t("customers.update.no_changes.title"),
t("customers.update.no_changes.message")
);
return;
}
//const previousData = customerData; //const previousData = customerData;
const patchData = buildCustomerUpdatePatch(formData, form.formState.dirtyFields); 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 // No revierto el form para que no se pierdan los cambios que
// ha hecho el usuario y no han sido guardados. // ha hecho el usuario y no han sido guardados.
/*form.reset( /*form.reset(
previousData ? mapCustomerToCustomerUpdateForm(previousData) : defaultCustomerUpdateForm, previousData ? mapCustomerToCustomerUpdateForm(previousData) : buildCustomerUpdateDefault(),
{ keepDirty: false } { keepDirty: false }
);*/ );*/
@ -186,6 +192,7 @@ export const useCustomerUpdateController = (
} }
}, },
(errors: FieldErrors<CustomerUpdateForm>) => { (errors: FieldErrors<CustomerUpdateForm>) => {
console.log(errors);
focusFirstCustomerUpdateFormError(form); focusFirstCustomerUpdateFormError(form);
showWarningToast( showWarningToast(
@ -196,7 +203,7 @@ export const useCustomerUpdateController = (
); );
// Evento onSubmit ya preparado para el <form> // Evento onSubmit ya preparado para el <form>
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => { const onSubmit = (event: React.SubmitEvent<HTMLFormElement>) => {
event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
submitHandler(event); submitHandler(event);
}; };

View File

@ -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",
};

View File

@ -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 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 * Es específico de la UI y no tiene por qué coincidir

View File

@ -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: * Reglas:
* - `undefined` => no enviar * - `undefined` => no enviar
@ -8,7 +9,7 @@
* - mantiene naming camelCase porque sigue siendo contrato frontend * - mantiene naming camelCase porque sigue siendo contrato frontend
*/ */
export interface CustomerUpdatePatch { export type CustomerUpdatePatch = {
reference?: string | null; reference?: string | null;
isCompany?: boolean; isCompany?: boolean;
name?: string; name?: string;
@ -38,4 +39,4 @@ export interface CustomerUpdatePatch {
languageCode?: string; languageCode?: string;
currencyCode?: string; currencyCode?: string;
} };

View File

@ -1,4 +1,3 @@
export * from "./customer-update-form.entity"; export * from "./customer-update-form.entity";
export * from "./customer-update-form.schema"; export * from "./customer-update-form.schema";
export * from "./customer-update-form-default.entity";
export * from "./customer-update-patch.entity"; export * from "./customer-update-patch.entity";

View File

@ -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",
};
};

View File

@ -1,3 +1,4 @@
export * from "./build-customer.update-patch"; export * from "./build-customer.update-patch";
export * from "./build-customer-update-default";
export * from "./build-update-customer-by-id-params"; export * from "./build-update-customer-by-id-params";
export * from "./focus-first-customer-update-form-error"; export * from "./focus-first-customer-update-form-error";

View File

@ -7,13 +7,23 @@ import { MoneyValue, Percentage, Quantity } from "../value-objects";
/** /**
* Aplica una transformación a un valor potencialmente nulo/indefinido. * Aplica una transformación a un valor potencialmente nulo/indefinido.
* *
* Si `input` es `null` o `undefined`, devuelve `null`. * Casos:
* En caso contrario, aplica `map`. * - `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 T - Tipo de entrada no nulo.
* @typeParam R - Tipo de salida. * @typeParam R - Tipo de salida.
* @param input - Valor potencialmente nulo o indefinido. * @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. * @returns Resultado de `map` o `null` si el input es nulo.
*/ */
function mapNullable<T, R>(input: T | null | undefined, map: (value: T) => R): R | null { function mapNullable<T, R>(input: T | null | undefined, map: (value: T) => R): R | null {
@ -23,10 +33,17 @@ function mapNullable<T, R>(input: T | null | undefined, map: (value: T) => R): R
/** /**
* Serializa un `Maybe<T>` a un valor de salida, aplicando una política explícita para `None`. * Serializa un `Maybe<T>` a un valor de salida, aplicando una política explícita para `None`.
* *
* - Si `Maybe` es `None`, devuelve `onNone`. * Casos:
* - Si es `Some`, aplica `map`. * - `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<string>(), null, (value) => value); // null
* serializeMaybe(Maybe.none<string>(), "", (value) => value); // ""
* serializeMaybe(Maybe.some("abc"), null, (value) => value.toUpperCase()); // "ABC"
* ```
* *
* @typeParam T - Tipo interno del Maybe. * @typeParam T - Tipo interno del Maybe.
* @typeParam R - Tipo de salida. * @typeParam R - Tipo de salida.
@ -40,18 +57,28 @@ function serializeMaybe<T, R>(maybe: Maybe<T>, 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: * Casos:
* - `null | undefined` `null` * - `null` `null`
* - `T` `map(T)` * - `undefined` `null`
* - valor directo `T` `map(value)`
* - `Maybe.none()` `null` * - `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<number>(), String); // null
* toNullable(Maybe.some(12), String); // "12"
* ```
*
* @typeParam V - Tipo de entrada.
* @typeParam R - Tipo de salida. * @typeParam R - Tipo de salida.
* @param input - Valor o `Maybe` a transformar. * @param input - Valor directo, nullable o `Maybe` a transformar.
* @param map - Función de transformación. * @param map - Función de transformación aplicada solo cuando hay valor.
* @returns Valor transformado o `null`. * @returns Valor transformado o `null`.
*/ */
export function toNullable<V, R>(input: null | undefined, map: (t: V) => R): null; export function toNullable<V, R>(input: null | undefined, map: (t: V) => R): null;
@ -69,18 +96,29 @@ export function toNullable<V, R>(
} }
/** /**
* Convierte un valor nullable en un `Maybe<T>` aplicando validación. * Convierte un valor nullable o vacío en un `Maybe<T>` aplicando validación.
* *
* - Si el valor es `null`, `undefined` o vacío `Maybe.none()` * Casos:
* - Si tiene valor aplica `validate` * - `null` `Result.ok(Maybe.none())`
* - Success `Maybe.some` * - `undefined` `Result.ok(Maybe.none())`
* - Failure propaga error * - `""` `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 T - Tipo validado.
* @typeParam S - Tipo de entrada. * @typeParam S - Tipo de entrada.
* @param input - Valor a validar. * @param input - Valor a validar.
* @param validate - Función de validación. * @param validate - Función de validación/construcción del valor cuando `input` tiene contenido.
* @returns Resultado con `Maybe<T>`. * @returns Resultado con `Maybe<T>` o error de validación.
*/ */
export function maybeFromNullableResult<T, S>( export function maybeFromNullableResult<T, S>(
input: S, input: S,
@ -97,17 +135,26 @@ export function maybeFromNullableResult<T, S>(
/** /**
* Serializa un `Maybe<T>` a un objeto aplicando una política de objeto vacío. * Serializa un `Maybe<T>` a un objeto aplicando una política de objeto vacío.
* *
* - `None` `emptyObject` * Casos:
* - `Some` `map(value)` o `defaultSerializer(value)` * - `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 * @internal
* *
* @typeParam T - Tipo interno del Maybe. * @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 maybe - Instancia `Maybe`.
* @param emptyObject - Objeto vacío a devolver si es `None`. * @param emptyObject - Objeto a devolver si es `None`.
* @param defaultSerializer - Serializador por defecto. * @param defaultSerializer - Serializador por defecto para `Some`.
* @param map - Serializador opcional personalizado. * @param map - Serializador opcional personalizado; prevalece sobre `defaultSerializer`.
* @returns Objeto serializado. * @returns Objeto serializado.
*/ */
function maybeToEmptyObjectString<T, R>( function maybeToEmptyObjectString<T, R>(
@ -124,11 +171,28 @@ function maybeToEmptyObjectString<T, R>(
/** /**
* Serializa un `Maybe<MoneyValue>` a un objeto de transporte. * Serializa un `Maybe<MoneyValue>` a un objeto de transporte.
* *
* - `None` objeto vacío de dinero * Casos:
* - `Some` `map` o `toObjectString()` * - `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 maybe - Maybe de `MoneyValue`.
* @param map - Transformación opcional. * @param map - Transformación opcional; si existe, sustituye a `toObjectString()`.
* @returns Objeto de dinero serializado. * @returns Objeto de dinero serializado.
*/ */
export function maybeToEmptyMoneyObjectString( export function maybeToEmptyMoneyObjectString(
@ -146,11 +210,27 @@ export function maybeToEmptyMoneyObjectString(
/** /**
* Serializa un `Maybe<Percentage>` a un objeto de transporte. * Serializa un `Maybe<Percentage>` a un objeto de transporte.
* *
* - `None` objeto vacío * Casos:
* - `Some` `map` o `toObjectString()` * - `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 maybe - Maybe de `Percentage`.
* @param map - Transformación opcional. * @param map - Transformación opcional; si existe, sustituye a `toObjectString()`.
* @returns Objeto de porcentaje serializado. * @returns Objeto de porcentaje serializado.
*/ */
export function maybeToEmptyPercentageObjectString( export function maybeToEmptyPercentageObjectString(
@ -168,11 +248,27 @@ export function maybeToEmptyPercentageObjectString(
/** /**
* Serializa un `Maybe<Quantity>` a un objeto de transporte. * Serializa un `Maybe<Quantity>` a un objeto de transporte.
* *
* - `None` objeto vacío * Casos:
* - `Some` `map` o `toObjectString()` * - `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 maybe - Maybe de `Quantity`.
* @param map - Transformación opcional. * @param map - Transformación opcional; si existe, sustituye a `toObjectString()`.
* @returns Objeto de cantidad serializado. * @returns Objeto de cantidad serializado.
*/ */
export function maybeToEmptyQuantityObjectString( export function maybeToEmptyQuantityObjectString(
@ -188,14 +284,26 @@ export function maybeToEmptyQuantityObjectString(
} }
/** /**
* Convierte un string nullable en `Maybe<string>`. * Convierte un string nullable o vacío en `Maybe<string>`.
* *
* - `null | undefined` `None` * Casos:
* - `""` o whitespace `None` * - `null` `Maybe.none()`
* - valor válido `Some(trimmed)` * - `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. * @param input - String de entrada.
* @returns Maybe del string. * @returns Maybe del string normalizado con `trim()`.
*/ */
export function maybeFromNullableOrEmptyString(input?: string | null): Maybe<string> { export function maybeFromNullableOrEmptyString(input?: string | null): Maybe<string> {
if (input == null) return Maybe.none<string>(); if (input == null) return Maybe.none<string>();
@ -207,14 +315,22 @@ export function maybeFromNullableOrEmptyString(input?: string | null): Maybe<str
/** /**
* Convierte un `Maybe<T>` en `R | null`. * Convierte un `Maybe<T>` en `R | null`.
* *
* - `None` `null` * Casos:
* - `Some` `map(value)` * - `Maybe.none()` `null`
* - `Maybe.some(value)` `map(value)`
*
* Ejemplos:
* ```ts
* maybeToNullable(Maybe.none<number>(), String); // null
* maybeToNullable(Maybe.some(12), String); // "12"
* maybeToNullable(Maybe.some("abc"), (value) => value.toUpperCase()); // "ABC"
* ```
* *
* @typeParam T - Tipo interno. * @typeParam T - Tipo interno.
* @typeParam R - Tipo de salida. * @typeParam R - Tipo de salida.
* @param maybe - Instancia Maybe. * @param maybe - Instancia Maybe.
* @param map - Transformación. * @param map - Transformación aplicada solo en caso `Some`.
* @returns Valor serializado o null. * @returns Valor serializado o `null`.
*/ */
export function maybeToNullable<T, R>(maybe: Maybe<T>, map: (t: T) => R): R | null { export function maybeToNullable<T, R>(maybe: Maybe<T>, map: (t: T) => R): R | null {
return serializeMaybe(maybe, null, map); return serializeMaybe(maybe, null, map);
@ -223,12 +339,22 @@ export function maybeToNullable<T, R>(maybe: Maybe<T>, map: (t: T) => R): R | nu
/** /**
* Convierte un `Maybe<T>` en `string | null`. * Convierte un `Maybe<T>` en `string | null`.
* *
* - `None` `null` * Casos:
* - `Some` `map(value)` o `String(value)` * - `Maybe.none()` `null`
* - `Maybe.some(value)` sin `map` `String(value)`
* - `Maybe.some(value)` con `map` `String(map(value))`
* *
* Ejemplos:
* ```ts
* maybeToNullableString(Maybe.none<number>()); // null
* maybeToNullableString(Maybe.some(12)); // "12"
* maybeToNullableString(Maybe.some(user), (value) => value.name); // "John"
* ```
*
* @typeParam T - Tipo interno.
* @param maybe - Instancia Maybe. * @param maybe - Instancia Maybe.
* @param map - Transformación opcional. * @param map - Transformación opcional.
* @returns String o null. * @returns String serializado o `null`.
*/ */
export function maybeToNullableString<T>(maybe: Maybe<T>, map?: (t: T) => string): string | null { export function maybeToNullableString<T>(maybe: Maybe<T>, map?: (t: T) => string): string | null {
return serializeMaybe(maybe, null, (value) => (map ? String(map(value)) : String(value))); return serializeMaybe(maybe, null, (value) => (map ? String(map(value)) : String(value)));
@ -237,12 +363,22 @@ export function maybeToNullableString<T>(maybe: Maybe<T>, map?: (t: T) => string
/** /**
* Convierte un `Maybe<T>` en `string`. * Convierte un `Maybe<T>` en `string`.
* *
* - `None` `""` * Casos:
* - `Some` `map(value)` o `String(value)` * - `Maybe.none()` `""`
* - `Maybe.some(value)` sin `map` `String(value)`
* - `Maybe.some(value)` con `map` `map(value)`
* *
* Ejemplos:
* ```ts
* maybeToEmptyString(Maybe.none<number>()); // ""
* maybeToEmptyString(Maybe.some(12)); // "12"
* maybeToEmptyString(Maybe.some(user), (value) => value.name); // "John"
* ```
*
* @typeParam T - Tipo interno.
* @param maybe - Instancia Maybe. * @param maybe - Instancia Maybe.
* @param map - Transformación opcional. * @param map - Transformación opcional.
* @returns String (nunca null). * @returns String serializado; nunca `null`.
*/ */
export function maybeToEmptyString<T>(maybe: Maybe<T>, map?: (t: T) => string): string { export function maybeToEmptyString<T>(maybe: Maybe<T>, map?: (t: T) => string): string {
return serializeMaybe(maybe, "", (value) => (map ? map(value) : String(value))); return serializeMaybe(maybe, "", (value) => (map ? map(value) : String(value)));

View File

@ -1,6 +1,8 @@
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { translateZodValidationError } from "../helpers"; import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object"; import { ValueObject } from "./value-object";
interface TINNumberProps { interface TINNumberProps {
@ -20,7 +22,8 @@ export class TINNumber extends ValueObject<TINNumberProps> {
}) })
.max(TINNumber.MAX_LENGTH, { .max(TINNumber.MAX_LENGTH, {
message: `TIN must be at most ${TINNumber.MAX_LENGTH} characters long`, 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); return schema.safeParse(value);
} }

View File

@ -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<TFormValues extends FieldValues> = {
name: FieldPath<TFormValues>;
label: string;
description?: string;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
orientation?: "vertical" | "horizontal" | "responsive";
className?: string;
inputClassName?: string;
};
export const CheckboxField = <TFormValues extends FieldValues>({
name,
label,
description,
disabled = false,
required = false,
readOnly = false,
orientation = "horizontal",
className,
inputClassName,
}: CheckboxFieldProps<TFormValues>) => {
const { control, formState } = useFormContext<TFormValues>();
const inputId = React.useId();
const descriptionId = description ? `${inputId}-description` : undefined;
const isDisabled = disabled || readOnly || formState.isSubmitting;
return (
<Controller
control={control}
name={name}
render={({ field, fieldState }) => {
const hasError = Boolean(fieldState.error);
return (
<Field
className={cn("gap-2", className)}
data-invalid={hasError}
orientation={orientation}
>
<Checkbox
aria-describedby={descriptionId}
aria-invalid={hasError || undefined}
aria-required={required || undefined}
checked={field.value === true}
className={cn(inputClassName)}
disabled={isDisabled}
id={inputId}
name={field.name}
onBlur={field.onBlur}
onCheckedChange={(checked) => {
field.onChange(checked === true);
}}
ref={field.ref}
required={required}
/>
<FieldContent className="gap-1">
<FormFieldLabel className="font-normal" htmlFor={inputId} required={required}>
{label}
</FormFieldLabel>
{description ? (
<FieldDescription id={descriptionId}>{description}</FieldDescription>
) : null}
<FieldError errors={[fieldState.error]} />
</FieldContent>
</Field>
);
}}
/>
);
};

View File

@ -1,3 +1,4 @@
export * from "./checkbox-field.tsx";
export * from "./date-picker-field.tsx"; export * from "./date-picker-field.tsx";
export * from "./date-picker-input-field/index.ts"; export * from "./date-picker-input-field/index.ts";
export * from "./decimal-field/index.ts"; export * from "./decimal-field/index.ts";

View File

@ -1,6 +1,7 @@
export * from "./collection"; export * from "./collection";
export * from "./id-utils"; export * from "./id-utils";
export * from "./maybe"; export * from "./maybe";
export * from "./object-helper";
export * from "./patch-field"; export * from "./patch-field";
export * from "./result"; export * from "./result";
export * from "./result-collection"; export * from "./result-collection";

View File

@ -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 = <T extends object>(obj: T, key: keyof T): boolean => {
return Object.hasOwn(obj, key);
};
export const ObjectHelper = {
hasOwn,
};

View File

@ -1,4 +1,5 @@
import Joi, { ValidationError } from "joi"; import Joi, { type ValidationError } from "joi";
import { Result } from "./result"; import { Result } from "./result";
export type TRuleValidatorResult<T> = Result<T, ValidationError>; export type TRuleValidatorResult<T> = Result<T, ValidationError>;

View File

@ -6,11 +6,12 @@ const config = {
"apps/**/*.{ts,tsx}", "apps/**/*.{ts,tsx}",
"../../packages/shadcn-ui/src/**/*.{ts,tsx}", "../../packages/shadcn-ui/src/**/*.{ts,tsx}",
"../../modules/**/*.{ts,tsx}", "../../modules/**/*.{ts,tsx}",
], ],
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [], plugins: [],
}; };
export default config; export default config;