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} />
</Suspense>
</TooltipProvider>
<Toaster expand={true} position="bottom-center" />
<Toaster expand={true} position="top-center" richColors />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</AuthProvider>
</DataSourceProvider>

View File

@ -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<typeof TinSchema>;

View File

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

View File

@ -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);

View File

@ -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(),

View File

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

View File

@ -151,7 +151,9 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return Result.ok(proforma);
}
private static validateCreateProps(props: IProformaCreateProps): Result<void, Error> {
private static validateCreateProps(
props: IProformaCreateProps | ProformaInternalProps
): Result<void, Error> {
return Result.ok();
}
@ -169,7 +171,14 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> 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);

View File

@ -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,

View File

@ -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),

View File

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

View File

@ -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) => {

View File

@ -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,

View File

@ -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

View File

@ -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 => {

View File

@ -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<CustomerSelectionOption | null>(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<ProformaUpdateForm>) => {
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 <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
submitHandler(event);
};

View File

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

View File

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

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 {
id: string;
position: number;
description: string;
isValued: boolean;
description: string | null;
quantity: number | null;
unitAmount: 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 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 { 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),
});

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.
* 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<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,
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}
/>
<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>
</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 { CopyIcon, MoveDownIcon, MoveUpIcon, Trash2Icon } from "lucide-react";
@ -34,6 +40,12 @@ export const ProformaUpdateItemRowEditor = ({
<Card>
<CardContent className="space-y-4 pt-6">
<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
className="col-span-12"
label={t("form_fields.items.description.label", "Descripción")}

View File

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

View File

@ -3,6 +3,19 @@ import type { FieldNamesMarkedBoolean } from "react-hook-form";
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 = (
formData: 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 { 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<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-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";

View File

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

View File

@ -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<CustomerUpdateForm>(() => {
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<CustomerUpdateForm>) => {
console.log(errors);
focusFirstCustomerUpdateFormError(form);
showWarningToast(
@ -196,7 +203,7 @@ export const useCustomerUpdateController = (
);
// 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
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 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:
* - `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;
}
};

View File

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

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-default";
export * from "./build-update-customer-by-id-params";
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.
*
* 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<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`.
*
* - 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<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 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:
* - `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<number>(), 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<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()`
* - 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<T>`.
* @param validate - Función de validación/construcción del valor cuando `input` tiene contenido.
* @returns Resultado con `Maybe<T>` o error de validación.
*/
export function maybeFromNullableResult<T, 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.
*
* - `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<T, R>(
@ -124,11 +171,28 @@ function maybeToEmptyObjectString<T, R>(
/**
* Serializa un `Maybe<MoneyValue>` 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<Percentage>` 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<Quantity>` 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<string>`.
* Convierte un string nullable o vacío en `Maybe<string>`.
*
* - `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<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`.
*
* - `None` `null`
* - `Some` `map(value)`
* Casos:
* - `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 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<T, R>(maybe: Maybe<T>, map: (t: T) => R): R | null {
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`.
*
* - `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<number>()); // 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<T>(maybe: Maybe<T>, map?: (t: T) => string): string | null {
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`.
*
* - `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<number>()); // ""
* 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<T>(maybe: Maybe<T>, map?: (t: T) => string): string {
return serializeMaybe(maybe, "", (value) => (map ? map(value) : String(value)));

View File

@ -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<TINNumberProps> {
})
.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);
}

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-input-field/index.ts";
export * from "./decimal-field/index.ts";

View File

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

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";
export type TRuleValidatorResult<T> = Result<T, ValidationError>;

View File

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