Repaso a customers

This commit is contained in:
David Arranz 2026-04-04 18:58:32 +02:00
parent cabea02dcb
commit e759da9916
62 changed files with 430 additions and 704 deletions

View File

@ -1,4 +0,0 @@
//export * from "./use-create-customer-mutation";
//export * from "./use-customer-query";
//export * from "./use-customers-context";
//export * from "./use-update-customer-mutation";

View File

@ -1,74 +0,0 @@
import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateCustomerRequestSchema } from "../../../common";
import { toValidationErrors } from "../../shared/hooks/to-validation-errors";
import type { Customer, CustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query";
import { setCustomerDetailCache } from "./use-customer-query";
export const CUSTOMER_CREATE_KEY = ["customers", "create"] as const;
type CreateCustomerPayload = {
data: CustomerFormData;
};
export function useCreateCustomer() {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const schema = CreateCustomerRequestSchema;
return useMutation<Customer, DefaultError, CreateCustomerPayload>({
mutationKey: CUSTOMER_CREATE_KEY,
mutationFn: async ({ data }, context) => {
const id = UniqueID.generateNewID().toString();
const payload = { ...data, id };
const result = schema.safeParse(payload);
if (!result.success) {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
}
const created = await dataSource.createOne("customers", payload);
return created as Customer;
},
onSuccess: (created: Customer, variables) => {
const { id: customerId } = created;
// Invalida el listado para refrescar desde servidor
invalidateCustomerListCache(queryClient);
// Sincroniza detalle
setCustomerDetailCache(queryClient, customerId, created);
},
onSettled: () => {
// Refresca todos los listados
invalidateCustomerListCache(queryClient);
},
onMutate: async ({ data }, context) => {
// Cancelar queries del listado para evitar overwrite
await queryClient.cancelQueries({ queryKey: [CUSTOMERS_LIST_KEY] });
const optimisticId = UniqueID.generateNewID().toString();
const optimisticCustomer: Customer = { ...data, id: optimisticId } as Customer;
// Snapshot previo
const previous = queryClient.getQueryData<Customer[]>([CUSTOMERS_LIST_KEY]);
// Optimista: prepend
queryClient.setQueryData<Customer[]>([CUSTOMERS_LIST_KEY], (old) => [
optimisticCustomer,
...(old ?? []),
]);
return { previous, optimisticId };
},
});
}

View File

@ -1,55 +0,0 @@
import { useDataSource } from "@erp/core/hooks";
import {
type DefaultError,
type QueryClient,
type QueryKey,
useQuery,
} from "@tanstack/react-query";
import type { Customer } from "../schemas";
export const CUSTOMER_DETAIL_SCOPE = "customers:detail" as const;
export const getCustomerQueryKey = (id: string) =>
[CUSTOMER_DETAIL_SCOPE, { id }] satisfies QueryKey;
type CustomerQueryOptions<TSelected> = {
enabled?: boolean;
staleTime?: number;
select?: (data: Customer) => TSelected;
placeholderData?: Customer;
};
export function useCustomerQuery<TSelected = Customer>(
customerId?: string,
options?: CustomerQueryOptions<TSelected>
) {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && Boolean(customerId);
return useQuery<Customer, DefaultError, TSelected>({
queryKey: getCustomerQueryKey(customerId ?? "unknown"),
queryFn: async (context) => {
const { signal } = context;
if (!customerId) {
if (!customerId) throw new Error("customerId is required");
}
return await dataSource.getOne<Customer>("customers", customerId, { signal });
},
enabled,
staleTime: options?.staleTime ?? 60_000, // 1 min por defecto
select: options?.select,
placeholderData: options?.placeholderData,
});
}
export function invalidateCustomerDetailCache(qc: QueryClient, id: string) {
return qc.invalidateQueries({
queryKey: getCustomerQueryKey(id ?? "unknown"),
exact: Boolean(id),
});
}
export function setCustomerDetailCache(qc: QueryClient, id: string, data: unknown) {
qc.setQueryData(getCustomerQueryKey(id), data);
}

View File

@ -1,12 +0,0 @@
import { useContext } from "react";
import { CustomersContext, type CustomersContextType } from "../context";
export const useCustomersContext = (): CustomersContextType => {
const context = useContext(CustomersContext);
if (!context) {
throw new Error("useCustomers must be used within a CustomersProvider");
}
return context;
};

View File

@ -1,57 +0,0 @@
import { useDataSource } from "@erp/core/hooks";
import {
type DefaultError,
type QueryKey,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import type { CustomersPage } from "../schemas";
import { cancelCustomerListQueries, deleteCustomerIntoListCaches } from "./use-customer-list-query";
import { invalidateCustomerDetailCache } from "./use-customer-query";
export const CUSTOMER_DELETE_KEY = ["customers", "delete"] as const;
type DeleteCustomerPayload = {
id: string;
};
export function useDeleteCustomer() {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<{ id: string }, DefaultError, DeleteCustomerPayload>({
mutationKey: CUSTOMER_DELETE_KEY,
mutationFn: async ({ id: customerId }) => {
if (!customerId) {
throw new Error("customerId is required");
}
await dataSource.deleteOne("customers", customerId);
return { id: customerId };
},
onMutate: async ({ id }) => {
await cancelCustomerListQueries(queryClient);
return deleteCustomerIntoListCaches(queryClient, id);
},
onError: (_e, _v, ctx) => {
if (!ctx) return;
const { snapshots } = ctx as ReturnType<typeof deleteCustomerIntoListCaches>;
for (const snap of snapshots as Array<{ key: QueryKey; page?: CustomersPage }>) {
if (snap.page) queryClient.setQueryData(snap.key, snap.page);
}
},
onSuccess: ({ id }) => {
invalidateCustomerDetailCache(queryClient, id);
},
onSettled: (data) => {
deleteCustomerIntoListCaches(queryClient, data?.id ?? "unknown");
},
});
}

View File

@ -1,65 +0,0 @@
import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerByIdRequestSchema } from "../../../common";
import { toValidationErrors } from "../../shared/hooks/to-validation-errors";
import type { Customer, CustomerFormData } from "../schemas";
import {
invalidateCustomerListCache,
upsertCustomerIntoListCaches,
} from "./use-customer-list-query";
import { setCustomerDetailCache } from "./use-customer-query";
export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const;
type UpdateCustomerContext = {};
type UpdateCustomerPayload = {
id: string;
data: Partial<CustomerFormData>;
};
export function useUpdateCustomer() {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const schema = UpdateCustomerByIdRequestSchema;
return useMutation<Customer, DefaultError, UpdateCustomerPayload, UpdateCustomerContext>({
mutationKey: CUSTOMER_UPDATE_KEY,
mutationFn: async (payload) => {
const { id: customerId, data } = payload;
if (!customerId) {
throw new Error("customerId is required");
}
const result = schema.safeParse(data);
if (!result.success) {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
}
const updated = await dataSource.updateOne("customers", customerId, data);
return updated as Customer;
},
onSuccess: (updated: Customer, variables) => {
const { id: customerId } = updated;
// Invalida el listado para refrescar desde servidor
invalidateCustomerListCache(queryClient);
// Actualiza detalle
setCustomerDetailCache(queryClient, customerId, updated);
// Actualiza todas las páginas donde aparezca
upsertCustomerIntoListCaches(queryClient, { ...updated });
},
onSettled: () => {
// Refresca todos los listados
invalidateCustomerListCache(queryClient);
},
});
}

View File

@ -1,80 +0,0 @@
import { PageHeader } from "@erp/core/components";
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { CustomerEditForm, ErrorAlert } from "../../components";
import { useCustomerCreateController } from "./use-customer-create-controller";
export const CustomerCreatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
form,
formId,
onSubmit,
resetForm,
isCreating,
isCreateError,
createError,
FormProvider,
} = useCustomerCreateController({
onCreated: (created) =>
navigate("/customers/list", {
state: { customerId: created.id, isNew: true },
replace: true,
}),
});
return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<PageHeader
backIcon
description={t("pages.create.description")}
rightSlot={
<UpdateCommitButtonGroup
cancel={{
to: "/customers/list",
disabled: isCreating,
}}
disabled={isCreating}
isLoading={isCreating}
onReset={resetForm}
submit={{
formId: formId,
disabled: isCreating,
}}
/>
}
title={t("pages.create.title")}
/>
</AppHeader>
<AppContent>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isCreateError && (
<ErrorAlert
message={
(createError as Error)?.message ??
t("pages.create.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.create.errorTitle", "No se pudo guardar los cambios")}
/>
)}
<FormProvider {...form}>
<CustomerEditForm
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6" // para que el botón del header pueda hacer submit
formId={formId}
onSubmit={onSubmit}
/>
</FormProvider>
</AppContent>
</UnsavedChangesProvider>
);
};

View File

@ -1 +0,0 @@
export * from "./customer-create-page";

View File

@ -1,107 +0,0 @@
import { useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useId } from "react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { useCreateCustomer } from "../../hooks";
import {
type Customer,
type CustomerFormData,
CustomerFormSchema,
defaultCustomerFormData,
} from "../../schemas";
export interface UseCustomerCreateControllerOptions {
onCreated?(created: Customer): void;
successToasts?: boolean; // mostrar o no toast automáticcamente
onError?(error: Error, data: CustomerFormData): void;
errorToasts?: boolean; // mostrar o no toast automáticcamente
}
export const useCustomerCreateController = (options?: UseCustomerCreateControllerOptions) => {
const { t } = useTranslation();
const formId = useId(); // id único por instancia
// 1) Estado de creación (mutación)
const {
mutateAsync,
isPending: isCreating,
isError: isCreateError,
error: createError,
} = useCreateCustomer();
// 2) Form hook
const form = useHookForm<CustomerFormData>({
resolverSchema: CustomerFormSchema,
initialValues: defaultCustomerFormData,
disabled: isCreating,
});
/** Handlers */
const resetForm = () => form.reset(defaultCustomerFormData);
// Versión sincronizada
const submitHandler = form.handleSubmit(async (formData) => {
try {
// Enviamos cambios al servidor
const created = await mutateAsync({ data: formData });
// Ha ido bien -> actualizamos form con datos reales
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
form.reset(created, { keepDirty: false });
console.log("form.formState =>", form.formState);
if (options?.successToasts !== false) {
showSuccessToast(
t("pages.create.success.title", "Cliente creado"),
t("pages.create.success.message", "Se ha creado correctamente.")
);
}
options?.onCreated?.(created);
} catch (err: unknown) {
console.log("No se pudo crear el cliente.");
// 1) Enfoca el primer error
/*const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined;
if (firstKey) {
const el = document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`);
el?.focus();
}*/
const error = err as Error;
if (options?.errorToasts !== false) {
showErrorToast(t("pages.update.error.title"), error.message);
}
options?.onError?.(error, formData);
}
});
// Evento onSubmit ya preparado para el <form>
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
submitHandler(event);
};
return {
// form
form,
formId,
// handlers del form
onSubmit,
resetForm,
// mutation
isCreating,
isCreateError,
createError,
// Por comodidad
FormProvider,
};
};

View File

@ -1,41 +0,0 @@
import type { InvoiceItem } from "@/types/invoice";
export function calculateItemTotal(quantity: number, unitPrice: number, discount = 0): number {
const subtotal = quantity * unitPrice;
const discountAmount = (subtotal * discount) / 100;
return subtotal - discountAmount;
}
export function calculateInvoiceTotals(items: InvoiceItem[], taxRate = 21) {
const subtotal = items.reduce((sum, item) => {
return (
sum + (item.quantity.amount * item.unit_price.amount) / Math.pow(10, item.unit_price.scale)
);
}, 0);
const totalDiscount = items.reduce((sum, item) => {
const itemSubtotal =
(item.quantity.amount * item.unit_price.amount) / Math.pow(10, item.unit_price.scale);
return sum + (itemSubtotal * item.discount.amount) / Math.pow(10, item.discount.scale) / 100;
}, 0);
const beforeTax = subtotal - totalDiscount;
const taxAmount = (beforeTax * taxRate) / 100;
const total = beforeTax + taxAmount;
return {
subtotal: Math.round(subtotal * 100),
totalDiscount: Math.round(totalDiscount * 100),
beforeTax: Math.round(beforeTax * 100),
taxAmount: Math.round(taxAmount * 100),
total: Math.round(total * 100),
};
}
export function formatCurrency(amount: number, scale = 2, currency = "EUR"): string {
const value = amount / Math.pow(10, scale);
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: currency,
}).format(value);
}

View File

@ -4,21 +4,20 @@ import { useId } from "react";
import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../i18n";
import type { Customer } from "../../shared";
import type { CreateCustomerParams, Customer } from "../../shared";
import { useCustomerCreateMutation } from "../../shared/hooks/use-customer-create-mutation";
import {
type CustomerCreateForm,
CustomerCreateFormSchema,
type CustomerCreatePayload,
defaultCustomerCreateForm,
} from "../entities";
import { buildCustomerCreatePayload } from "../utils";
import { buildCreateCustomerParams } from "../utils";
export interface UseCustomerCreateControllerOptions {
onCreated?(created: Customer): void;
successToasts?: boolean; // mostrar o no toast automáticcamente
onError?(error: Error, payloadData: CustomerCreatePayload): void;
onError?(error: Error, inputPayload: CreateCustomerParams): void;
errorToasts?: boolean; // mostrar o no toast automáticcamente
}
@ -49,11 +48,11 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
const submitHandler = form.handleSubmit(
async (formData) => {
const payloadData: CustomerCreatePayload = buildCustomerCreatePayload(formData);
const inputPayload: CreateCustomerParams = buildCreateCustomerParams(formData);
try {
// Enviamos cambios al servidor
const created = await mutateAsync(payloadData);
const created = await mutateAsync(inputPayload); // payload es CustomerCreatePayload
if (options?.successToasts !== false) {
showSuccessToast(
@ -70,7 +69,7 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
showErrorToast(t("pages.create.error.title"), normalizedError.message);
}
options?.onError?.(normalizedError, payloadData);
options?.onError?.(normalizedError, inputPayload);
}
},
(errors: FieldErrors<CustomerCreateForm>) => {

View File

@ -15,33 +15,33 @@ import { z } from "zod/v4";
*/
export const CustomerCreateFormSchema = z.object({
reference: z.string().optional().or(z.literal("")),
reference: z.string(),
isCompany: z.boolean(),
name: z.string().min(1, "El nombre es obligatorio"),
tradeName: z.string().optional().or(z.literal("")),
tradeName: z.string(),
tin: z.string(),
defaultTaxes: z.array(z.string()),
street: z.string().optional().or(z.literal("")),
street2: z.string().optional().or(z.literal("")),
city: z.string().optional().or(z.literal("")),
province: z.string().optional().or(z.literal("")),
postalCode: z.string().optional().or(z.literal("")),
country: z.string().min(1, "El país es obligatorio").optional().or(z.literal("")),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postalCode: z.string(),
country: z.string().min(1, "El país es obligatorio"),
primaryEmail: z.email("Email inválido").optional().or(z.literal("")),
secondaryEmail: z.email("Email inválido").optional().or(z.literal("")),
primaryEmail: z.email("Email inválido"),
secondaryEmail: z.email("Email inválido"),
primaryPhone: z.string().optional().or(z.literal("")),
secondaryPhone: z.string().optional().or(z.literal("")),
primaryMobile: z.string().optional().or(z.literal("")),
secondaryMobile: z.string().optional().or(z.literal("")),
primaryPhone: z.string(),
secondaryPhone: z.string(),
primaryMobile: z.string(),
secondaryMobile: z.string(),
fax: z.string().optional().or(z.literal("")),
website: z.url("URL inválida").optional().or(z.literal("")),
fax: z.string(),
website: z.url("URL inválida"),
legalRecord: z.string().optional().or(z.literal("")),
legalRecord: z.string(),
languageCode: z.string().min(1, "El idioma es obligatorio"),
currencyCode: z.string().min(1, "La moneda es obligatoria"),

View File

@ -1,23 +0,0 @@
import type { CustomerCreateForm } from "./customer-create-form.entity";
/**
* CustomerCreatePayload es un tipo que representa un objeto con las mismas
* propiedades que CustomerCreateForm, pero todas ellas son opcionales.
*
* Esto es útil para representar los datos que se van a enviar a la API para crear un cliente,
* ya que en una creación parcial (POST) no es necesario enviar todos los campos,
* sino solo aquellos que se quieren modificar.
*
* Reglas:
* - debe ser un Partial de CustomerCreateForm
* - no debe tener campos adicionales ni transformaciones
* - debe ser un shape orientado a la API, no a la UI ni al dominio
* - sin shape DTO, solo tipos simples y directos
*/
export type CustomerCreatePayload = Partial<CustomerCreateForm> & {
// Aquí se añaden los campos que la API requiera para la creación de un cliente y que no estén en el formulario.
// Por ejemplo:
// - id: string;
id: string;
};

View File

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

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -5,13 +5,17 @@ import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from "./customer-contact-fields";
type CustomerFormProps = {
type CustomerCreateEditorFormProps = {
formId: string;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
className?: string;
};
export const CustomerCreateForm = ({ formId, onSubmit, className }: CustomerFormProps) => {
export const CustomerCreateEditorForm = ({
formId,
onSubmit,
className,
}: CustomerCreateEditorFormProps) => {
return (
<form id={formId} noValidate onSubmit={onSubmit}>
<section className={cn("space-y-12 p-6", className)}>

View File

@ -0,0 +1 @@
export * from "./customer-create-editor-form";

View File

@ -0,0 +1 @@
export * from "./pages";

View File

@ -5,8 +5,7 @@ import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { useCustomerCreatePageController } from "../../controllers";
import { CustomerCreateForm } from "./editor";
import { CustomerCreateEditorForm } from "../editor";
export const CustomerCreatePage = () => {
const { t } = useTranslation();
@ -42,7 +41,7 @@ export const CustomerCreatePage = () => {
)}
<FormProvider {...form}>
<CustomerCreateForm
<CustomerCreateEditorForm
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6"
formId={formId}
onSubmit={onSubmit}

View File

@ -1 +0,0 @@
export * from "./customer-create-form";

View File

@ -0,0 +1,58 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { CreateCustomerParams } from "../../shared";
import type { CustomerCreateForm } from "../entities";
/**
* Construye un payload de creación de cliente a partir de los datos
* del formulario y los campos sucios.
*
* Reglas:
* - el payload debe ser un objeto con solo las propiedades que han cambiado (campos sucios).
* - no debe incluir campos que no han cambiado.
* - el shape del payload debe coincidir con el de CustomerCreatePayload,
* es decir, orientado a la API.
* - no debe tener transformaciones ni campos adicionales, solo los que vienen del
* formulario y están sucios.
*
* @param formData - Los datos del formulario de creación de cliente.
* @returns Un objeto que se puede enviar a la API para crear un cliente,
*
*/
export const buildCreateCustomerParams = (formData: CustomerCreateForm): CreateCustomerParams => {
return {
// Generamos un ID único para el nuevo cliente.
// La API de creación de cliente requiere un ID,
id: UniqueID.generateNewID().toString(),
reference: formData.reference,
is_company: formData.isCompany ? "1" : "0",
name: formData.name,
trade_name: formData.tradeName,
tin: formData.tin,
default_taxes: formData.defaultTaxes,
street: formData.street,
street2: formData.street2,
city: formData.city,
province: formData.province,
postal_code: formData.postalCode,
country: formData.country,
email_primary: formData.primaryEmail,
email_secondary: formData.secondaryEmail,
phone_primary: formData.primaryPhone,
phone_secondary: formData.secondaryPhone,
mobile_primary: formData.primaryMobile,
mobile_secondary: formData.secondaryMobile,
fax: formData.fax,
website: formData.website,
legal_record: formData.legalRecord,
language_code: formData.languageCode,
currency_code: formData.currencyCode,
};
};

View File

@ -1,29 +0,0 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { CustomerCreateForm, CustomerCreatePayload } from "../entities";
/**
* Construye un payload de creación de cliente a partir de los datos
* del formulario y los campos sucios.
*
* Reglas:
* - el payload debe ser un objeto con solo las propiedades que han cambiado (campos sucios).
* - no debe incluir campos que no han cambiado.
* - el shape del payload debe coincidir con el de CustomerCreatePayload,
* es decir, orientado a la API.
* - no debe tener transformaciones ni campos adicionales, solo los que vienen del
* formulario y están sucios.
*
* @param formData: Los datos del formulario de creación de cliente.
* @returns Un objeto que se puede enviar a la API para crear un cliente,
* con todos los campos necesarios.
*/
export const buildCustomerCreatePayload = (formData: CustomerCreateForm): CustomerCreatePayload => {
return {
...formData,
// El backend exige que el cliente envíe un id en la creación.
id: UniqueID.generateNewID().toString(),
};
};

View File

@ -1 +1 @@
export * from "./build-customer-create-payload";
export * from "./build-customer-create-params";

View File

@ -9,7 +9,9 @@ const CustomerLayout = lazy(() =>
const CustomersList = lazy(() => import("./list").then((m) => ({ default: m.ListCustomersPage })));
const CustomerView = lazy(() => import("./view").then((m) => ({ default: m.CustomerViewPage })));
const CustomerAdd = lazy(() => import("./create").then((m) => ({ default: m.CustomerCreatePage })));
const CustomerCreate = lazy(() =>
import("./create").then((m) => ({ default: m.CustomerCreatePage }))
);
const CustomerUpdate = lazy(() =>
import("./update").then((m) => ({ default: m.CustomerUpdatePage }))
);
@ -29,9 +31,10 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
//{ path: "create", element: <CustomerAdd /> },
{ path: ":id", element: <CustomerView /> },
{ path: ":id/edit", element: <CustomerUpdate /> },
{ path: "create", element: <CustomerCreate /> },
//
/*{ path: "create", element: <CustomersList /> },
/*
{ path: ":id", element: <CustomersList /> },
{ path: ":id/edit", element: <CustomersList /> },
{ path: ":id/delete", element: <CustomersList /> },

View File

@ -2,7 +2,7 @@ import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react";
import { useListCustomersQuery } from "../../shared";
import { useCustomerListQuery } from "../../shared";
export const useListCustomersController = () => {
const [pageIndex, setPageIndex] = useState(0);
@ -21,7 +21,7 @@ export const useListCustomersController = () => {
};
}, [pageSize, pageIndex, debouncedQ]);
const query = useListCustomersQuery({ criteria });
const query = useCustomerListQuery({ criteria });
const setSearchValue = (value: string) => {
const nextValue = value.trim().replace(/\s+/g, " ");

View File

@ -1,6 +1,18 @@
import type { Customer } from "../entities/customer.entity";
import type { CustomerListRow } from "../entities/customer-list-row.entity";
/**
* Adaptador para transformar un objeto Customer a un objeto CustomerListRowPatch,
* que es una versión parcial de CustomerListRow.
* Utilizado por el gestor de caché para actualizar algunos campos del objeto CustomerListRow.
* Reglas de adaptación:
* - los campos se asignan directamente
* - los campos son opcionales excepto el id.
*
* @param customer - objeto Customer a adaptar.
* @returns {CustomerListRowPatch} Objeto adaptado a CustomerListRowPatch.
*/
export type CustomerListRowPatch = Pick<CustomerListRow, "id"> & Partial<CustomerListRow>;
export const CustomerToListRowPatchAdapter = {

View File

@ -1,11 +1,24 @@
import type { CustomerCreationResponseDTO } from "@erp/customers/common";
import type { CustomerGetOutput, CustomerUpdateOutput } from "../api";
import type { GetCustomerByIdResult, UpdateCustomerByIdResult } from "../api";
import type { Customer } from "../entities";
/**
* Adaptador para transformar los datos de la API de GetCustomerByIdResult, CustomerCreationResponseDTO o UpdateCustomerByIdResult
* a la entidad Customer utilizada en la aplicación.
* Reglas de adaptación:
* - id, company_id, reference se asignan directamente.
* - is_company se convierte a booleano (true si es "1", false si es "0").
* - default_taxes se divide por ";" y se filtran los valores vacíos o "#".
*
* @params dto - datos del cliente desde la API.
* @param context - Contexto adicional opcional para la adaptación.
* @returns {Customer} Objeto adaptado a Customer.
*/
export const GetCustomerByIdAdapter = {
fromDTO(
dto: CustomerGetOutput | CustomerCreationResponseDTO | CustomerUpdateOutput,
dto: GetCustomerByIdResult | CustomerCreationResponseDTO | UpdateCustomerByIdResult,
context?: unknown
): Customer {
const taxesAdapter = (taxes: string) =>

View File

@ -1,8 +1,20 @@
import type { CustomerListOutput } from "../api";
import type { ListCustomersResult } from "../api";
import type { CustomerList, CustomerListRow } from "../entities";
/**
* Adaptador para transformar los datos de la API de ListCustomersResult
* a la entidad CustomerList utilizada en la aplicación.
* Reglas de adaptación:
* - page, per_page, total_pages, total_items se asignan directamente.
* - items se transforma utilizando ListCustomersRowAdapter para cada elemento.
*
* @param pageDto - lista de clientes desde la API.
* @param context - Contexto adicional opcional para la adaptación.
* @returns {CustomerList} Objeto adaptado a CustomerList.
*/
export const ListCustomersAdapter = {
fromDTO(pageDto: CustomerListOutput, context?: unknown): CustomerList {
fromDTO(pageDto: ListCustomersResult, context?: unknown): CustomerList {
return {
page: pageDto.page,
per_page: pageDto.per_page,
@ -13,7 +25,18 @@ export const ListCustomersAdapter = {
},
};
type CustomerListItemOutput = CustomerListOutput["items"][number];
/**
* Adaptador para transformar los items de la API de ListCustomersResult a la entidad CustomerListRow.
* Reglas de adaptación:
* - id, company_id, status, reference se asignan directamente.
* - is_company se convierte a booleano (true si es "1", false si es "0").
*
* @param rowDto - item de cliente desde la API.
* @param context - Contexto adicional opcional para la adaptación.
* @returns {CustomerListRow} Objeto adaptado a CustomerListRow.
*/
type CustomerListItemOutput = ListCustomersResult["items"][number];
const ListCustomersRowAdapter = {
fromDTO(rowDto: CustomerListItemOutput, context?: unknown): CustomerListRow {

View File

@ -2,13 +2,21 @@ import type { IDataSource } from "@erp/core/client";
import type { CreateCustomerRequestDTO, CustomerCreationResponseDTO } from "../../../common";
export type CustomerCreateInput = CreateCustomerRequestDTO;
export type CustomerCreateOutput = CustomerCreationResponseDTO;
/**
* Crea un nuevo cliente en el sistema utilizando la fuente de datos proporcionada.
*
* @param dataSource - La fuente de datos para interactuar con la API.
* @param params - Los parámetros necesarios para crear el cliente.
* @returns Una promesa que resuelve con los detalles del cliente creado.
* @throws Error si el ID del cliente no es proporcionado o si la creación falla.
*/
export function createCustomer(dataSource: IDataSource, id: string, data: CustomerCreateInput) {
export type CreateCustomerParams = CreateCustomerRequestDTO;
export type CreateCustomerResult = CustomerCreationResponseDTO;
export function createCustomer(dataSource: IDataSource, params: CreateCustomerParams) {
const { id } = params;
if (!id) throw new Error("customerId is required");
return dataSource.createOne<CustomerCreateInput, CustomerCreateOutput>("customers", {
...data,
id,
});
return dataSource.createOne<CreateCustomerRequestDTO, CreateCustomerResult>("customers", params);
}

View File

@ -1,8 +1,29 @@
import type { IDataSource } from "@erp/core/client";
import type { DeleteCustomerByIdRequestDTO } from "../../../common";
export type CustomerDeleteInput = DeleteCustomerByIdRequestDTO;
/**
* Elimina un cliente existente en el sistema utilizando la fuente de datos proporcionada.
*
* @param dataSource - La fuente de datos para interactuar con la API.
* @param params - Los parámetros necesarios para eliminar el cliente.
* @param signal - Un AbortSignal para cancelar la solicitud si es necesario.
* @returns Una promesa que se resuelve cuando el cliente ha sido eliminado exitosamente.
* @throws Error si el ID del cliente no es proporcionado o si la eliminación falla.
*/
export function deleteCustomerById(dataSource: IDataSource, id: string, signal: AbortSignal) {
return dataSource.deleteOne<CustomerDeleteInput>("customers", id, { signal });
export type DeleteCustomerByIdParams = {
id: string;
};
export type DeleteCustomerByIdResult = void;
export function deleteCustomerById(
dataSource: IDataSource,
params: DeleteCustomerByIdParams,
signal?: AbortSignal
): Promise<DeleteCustomerByIdResult> {
const { id } = params;
if (!id) throw new Error("customerId is required");
return dataSource.deleteOne<DeleteCustomerByIdResult>("customers", id, {
signal,
});
}

View File

@ -2,8 +2,28 @@ import type { IDataSource } from "@erp/core/client";
import type { GetCustomerByIdResponseDTO } from "../../../common";
export type CustomerGetOutput = GetCustomerByIdResponseDTO;
/**
* Recupera los detalles de un cliente específico utilizando su ID a través de la fuente de datos proporcionada.
*
* @param dataSource - La fuente de datos para interactuar con la API.
* @param params - Los parámetros necesarios para obtener el cliente, incluyendo su ID.
* @param signal - Un AbortSignal para cancelar la solicitud si es necesario.
* @returns Una promesa que resuelve con los detalles del cliente solicitado.
* @throws Error si el ID del cliente no es proporcionado o si la recuperación falla.
*/
export function getCustomerById(dataSource: IDataSource, id: string, signal: AbortSignal) {
return dataSource.getOne<CustomerGetOutput>("customers", id, { signal });
export type GetCustomerByIdParams = {
id: string;
};
export type GetCustomerByIdResult = GetCustomerByIdResponseDTO;
export function getCustomerById(
dataSource: IDataSource,
params: GetCustomerByIdParams,
signal?: AbortSignal
) {
const { id } = params;
if (!id) throw new Error("customerId is required");
return dataSource.getOne<GetCustomerByIdResult>("customers", id, { signal });
}

View File

@ -0,0 +1,33 @@
import type { CriteriaDTO } from "@erp/core";
import type { IDataSource } from "@erp/core/client";
import type { ListCustomersResponseDTO } from "../../../common";
/**
* Recupera una lista de clientes del sistema utilizando la
* fuente de datos proporcionada y los criterios de búsqueda especificados.
*
* @param dataSource - La fuente de datos para interactuar con la API.
* @param params - Los parámetros necesarios para listar los clientes, incluyendo los criterios de búsqueda.
* @param signal - Un AbortSignal para cancelar la solicitud si es necesario.
* @returns Una promesa que resuelve con una lista de clientes que cumplen con los criterios especificados.
* @throws Error si la recuperación de la lista de clientes falla.
*/
export type ListCustomersByCriteriaParams = {
criteria: CriteriaDTO;
};
export type ListCustomersResult = ListCustomersResponseDTO;
export function getListCustomersByCriteria(
dataSource: IDataSource,
params: ListCustomersByCriteriaParams,
signal?: AbortSignal
): Promise<ListCustomersResult> {
const { criteria } = params;
return dataSource.getList<ListCustomersResult>("customers", {
signal,
...criteria,
});
}

View File

@ -1,5 +1,5 @@
export * from "./create-customer.api";
export * from "./delete-customer-by-id.api";
export * from "./get-customer-by-id.api";
export * from "./list-customers.api";
export * from "./get-list-customers-by-criteria.api";
export * from "./update-customer-by-id.api";

View File

@ -1,17 +0,0 @@
import type { CriteriaDTO } from "@erp/core";
import type { IDataSource } from "@erp/core/client";
import type { ListCustomersResponseDTO } from "../../../common";
export type CustomerListOutput = ListCustomersResponseDTO;
export function getListCustomers(
dataSource: IDataSource,
criteria: CriteriaDTO,
signal: AbortSignal
) {
return dataSource.getList<CustomerListOutput>("customers", {
signal,
...criteria,
});
}

View File

@ -2,10 +2,32 @@ import type { IDataSource } from "@erp/core/client";
import type { UpdateCustomerByIdRequestDTO, UpdateCustomerByIdResponseDTO } from "../../../common";
export type CustomerUpdateInput = UpdateCustomerByIdRequestDTO;
export type CustomerUpdateOutput = UpdateCustomerByIdResponseDTO;
/**
* Actualiza un cliente existente en el sistema utilizando la fuente de datos proporcionada.
*
* @param dataSource - La fuente de datos para interactuar con la API.
* @param params - Los parámetros necesarios para actualizar el cliente.
* @returns Una promesa que resuelve con los detalles del cliente actualizado.
* @throws Error si el ID del cliente no es proporcionado o si la actualización falla.
*/
export type UpdateCustomerByIdParams = {
id: string;
data: UpdateCustomerByIdRequestDTO;
};
export type UpdateCustomerByIdResult = UpdateCustomerByIdResponseDTO;
export function updateCustomerById(
dataSource: IDataSource,
params: UpdateCustomerByIdParams
): Promise<UpdateCustomerByIdResult> {
const { id, data } = params;
export function updateCustomerById(dataSource: IDataSource, id: string, data: CustomerUpdateInput) {
if (!id) throw new Error("customerId is required");
return dataSource.updateOne<CustomerUpdateInput, CustomerUpdateOutput>("customers", id, data);
return dataSource.updateOne<UpdateCustomerByIdRequestDTO, UpdateCustomerByIdResult>(
"customers",
id,
data
);
}

View File

@ -1,3 +1,8 @@
/**
* Interface que representa una fila en la lista de clientes, adaptada desde la respuesta de la API.
* Contiene solo ciertos campos básicos del cliente.
*
*/
export interface CustomerListRow {
id: string;
companyId: string;

View File

@ -1,5 +1,10 @@
import type { CustomerListRow } from "./customer-list-row.entity";
/**
* Interface que representa la respuesta paginada de una lista de clientes,
* adaptada desde la respuesta de la API.
*/
export interface CustomerList {
items: CustomerListRow[];
total_pages: number;

View File

@ -1,3 +1,9 @@
/**
* Interface que representa un cliente en el sistema,
* adaptada desde la respuesta de la API.
* Contiene todos los campos detallados del cliente.
*/
export interface Customer {
id: string;
companyId: string;

View File

@ -5,6 +5,12 @@ import type { Customer, CustomerList, CustomerListRow } from "../entities";
import { CUSTOMER_QUERY_KEY, LIST_CUSTOMERS_QUERY_KEY_PREFIX } from "./keys";
/**
* Estrategias de caché para las consultas relacionadas con clientes.
* Incluye funciones para cancelar, invalidar, actualizar y eliminar cachés de clientes,
* así como para manejar actualizaciones optimistas al eliminar clientes.
*/
export interface CustomerListCacheSnapshot {
key: QueryKey;
page?: CustomerList;

View File

@ -2,4 +2,4 @@ export * from "./use-customer-create-mutation";
export * from "./use-customer-delete-mutation";
export * from "./use-customer-get-query";
export * from "./use-customer-update-mutation";
export * from "./use-list-customers-query";
export * from "./use-customers-list-query";

View File

@ -1,6 +1,11 @@
import type { ZodError } from "zod";
// Helpers de validación a errores de dominio
/**
* Convierte un error de validación de Zod en una colección de errores de validación personalizada.
*
* @param error
* @returns array de objetos con el campo y el mensaje de error correspondiente.
*/
export function toValidationErrors(error: ZodError<unknown>) {
return error.issues.map((err) => ({

View File

@ -4,7 +4,7 @@ import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-
import { CreateCustomerRequestSchema } from "../../../common";
import { GetCustomerByIdAdapter } from "../adapters";
import { type CustomerCreateInput, createCustomer } from "../api";
import { type CreateCustomerParams, type CreateCustomerResult, createCustomer } from "../api";
import type { Customer } from "../entities";
import {
@ -21,18 +21,19 @@ export const useCustomerCreateMutation = () => {
const dataSource = useDataSource();
const schema = CreateCustomerRequestSchema;
return useMutation<Customer, DefaultError, CustomerCreateInput, CreateCustomerContext>({
return useMutation<Customer, DefaultError, CreateCustomerParams, CreateCustomerContext>({
mutationKey: CUSTOMER_CREATE_KEY,
mutationFn: async (payload) => {
const id = UniqueID.generateNewID().toString();
const payloadWithId: CreateCustomerParams = { ...payload, id };
const result = schema.safeParse(payload);
const result = schema.safeParse(payloadWithId);
if (!result.success) {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
}
const dto = await createCustomer(dataSource, id, payload);
const dto: CreateCustomerResult = await createCustomer(dataSource, payloadWithId);
return GetCustomerByIdAdapter.fromDTO(dto);
},

View File

@ -1,7 +1,11 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteCustomerById } from "../api";
import {
type DeleteCustomerByIdParams,
type DeleteCustomerByIdResult,
deleteCustomerById,
} from "../api";
import {
type DeleteCustomerCacheContext,
@ -12,25 +16,25 @@ import {
} from "./customer-cache-strategy";
import { CUSTOMER_DELETE_KEY } from "./keys";
export interface DeleteCustomerPayload {
id: string;
}
interface DeleteCustomerContext extends DeleteCustomerCacheContext {}
export const useCustomerDeleteMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<{ id: string }, DefaultError, DeleteCustomerPayload, DeleteCustomerContext>({
return useMutation<
DeleteCustomerByIdResult,
DefaultError,
DeleteCustomerByIdParams,
DeleteCustomerContext
>({
mutationKey: CUSTOMER_DELETE_KEY,
mutationFn: async ({ id }) => {
if (!id) {
throw new Error("customerId is required");
}
await deleteCustomerById(dataSource, id, new AbortController().signal);
return { id };
await deleteCustomerById(dataSource, { id }, new AbortController().signal);
},
onMutate: async ({ id }) => {
return prepareDeleteCustomerOptimisticUpdate(queryClient, id);
@ -40,8 +44,8 @@ export const useCustomerDeleteMutation = () => {
rollbackDeleteCustomerOptimisticUpdate(queryClient, context);
},
onSuccess: ({ id }) => {
finalizeDeletedCustomerCaches(queryClient, id);
onSuccess: (_, variables) => {
finalizeDeletedCustomerCaches(queryClient, variables.id);
},
onSettled: async () => {
await invalidateCustomerListQueries(queryClient);

View File

@ -2,7 +2,7 @@ import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { GetCustomerByIdAdapter } from "../adapters";
import { getCustomerById } from "../api";
import { type GetCustomerByIdResult, getCustomerById } from "../api";
import type { Customer } from "../entities";
import { CUSTOMER_QUERY_KEY } from "./keys";
@ -22,7 +22,11 @@ export const useCustomerGetQuery = (
return useQuery<Customer, DefaultError>({
queryKey: CUSTOMER_QUERY_KEY(customerId),
queryFn: async ({ signal }) => {
const dto = await getCustomerById(dataSource, String(customerId), signal);
const dto: GetCustomerByIdResult = await getCustomerById(
dataSource,
{ id: customerId! },
signal
);
return GetCustomerByIdAdapter.fromDTO(dto);
},
enabled,

View File

@ -4,7 +4,11 @@ import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-
import { UpdateCustomerByIdRequestSchema } from "../../../common";
import { GetCustomerByIdAdapter } from "../adapters";
import { type CustomerUpdateInput, updateCustomerById } from "../api";
import {
type UpdateCustomerByIdParams,
type UpdateCustomerByIdResult,
updateCustomerById,
} from "../api";
import type { Customer } from "../entities";
import {
@ -16,17 +20,12 @@ import { toValidationErrors } from "./to-validation-errors";
type UpdateCustomerContext = {};
type UpdateCustomerPayload = {
id: string;
data: CustomerUpdateInput;
};
export const useCustomerUpdateMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const schema = UpdateCustomerByIdRequestSchema;
return useMutation<Customer, DefaultError, UpdateCustomerPayload, UpdateCustomerContext>({
return useMutation<Customer, DefaultError, UpdateCustomerByIdParams, UpdateCustomerContext>({
mutationKey: CUSTOMER_UPDATE_KEY,
mutationFn: async (payload) => {
@ -40,7 +39,10 @@ export const useCustomerUpdateMutation = () => {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
}
const dto = await updateCustomerById(dataSource, customerId, data);
const dto: UpdateCustomerByIdResult = await updateCustomerById(
dataSource,
payload as UpdateCustomerByIdParams
);
return GetCustomerByIdAdapter.fromDTO(dto);
},

View File

@ -3,18 +3,18 @@ import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { ListCustomersAdapter } from "../adapters";
import { getListCustomers } from "../api";
import { type ListCustomersResult, getListCustomersByCriteria } from "../api";
import type { CustomerList } from "../entities";
import { LIST_CUSTOMERS_QUERY_KEY } from "./keys";
type ListCustomersQueryOptions = {
type CustomerListQueryOptions = {
enabled?: boolean;
criteria?: Partial<CriteriaDTO>;
};
export const useListCustomersQuery = (
options?: ListCustomersQueryOptions
export const useCustomerListQuery = (
options?: CustomerListQueryOptions
): UseQueryResult<CustomerList, DefaultError> => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
@ -23,7 +23,11 @@ export const useListCustomersQuery = (
return useQuery<CustomerList, DefaultError>({
queryKey: LIST_CUSTOMERS_QUERY_KEY(criteria),
queryFn: async ({ signal }) => {
const dto = await getListCustomers(dataSource, criteria as CriteriaDTO, signal);
const dto: ListCustomersResult = await getListCustomersByCriteria(
dataSource,
{ criteria },
signal
);
return ListCustomersAdapter.fromDTO(dto);
},
enabled,

View File

@ -3,8 +3,6 @@ import type { CustomerUpdateForm } from "../entities";
/**
* Mapea un cliente a un formulario de actualización de cliente.
* Es decir, adapta el shape de datos del dominio al shape de datos
* que necesita la UI para mostrar el formulario de actualización.
*
* @param customer
* @returns

View File

@ -5,21 +5,25 @@ import { useEffect, useId, useMemo } from "react";
import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { type Customer, useCustomerGetQuery, useCustomerUpdateMutation } from "../../shared";
import {
type Customer,
type UpdateCustomerByIdParams,
useCustomerGetQuery,
useCustomerUpdateMutation,
} from "../../shared";
import { mapCustomerToCustomerUpdateForm } from "../adapters";
import {
type CustomerUpdateForm,
CustomerUpdateFormSchema,
type CustomerUpdatePatch,
defaultCustomerUpdateForm,
} from "../entities";
import { buildCustomerUpdatePatch } from "../utils";
import { buildCustomerUpdatePatch, buildUpdateCustomerByIdParams } from "../utils";
export interface UseCustomerUpdateControllerOptions {
onUpdated?(updated: Customer): void;
successToasts?: boolean; // mostrar o no toast automáticamente
onError?(error: Error, patchData: CustomerUpdatePatch): void;
onError?(error: Error, params: UpdateCustomerByIdParams): void;
errorToasts?: boolean; // mostrar o no toast automáticamente
}
@ -93,12 +97,14 @@ export const useCustomerUpdateController = (
return;
}
const patchData: CustomerUpdatePatch = buildCustomerUpdatePatch(formData, dirtyFields);
const previousData = customerData;
const patchData = buildCustomerUpdatePatch(formData, dirtyFields);
const params: UpdateCustomerByIdParams = buildUpdateCustomerByIdParams(customerId, patchData);
try {
// Enviamos cambios al servidor
const updated = await mutateAsync({ id: customerId, data: patchData });
const updated = await mutateAsync(params);
// Ha ido bien -> actualizamos form con datos reales
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
@ -127,7 +133,7 @@ export const useCustomerUpdateController = (
showErrorToast(t("pages.update.error.title"), normalizedError.message);
}
options?.onError?.(normalizedError, patchData);
options?.onError?.(normalizedError, params);
}
},
(errors: FieldErrors<CustomerUpdateForm>) => {

View File

@ -2,7 +2,7 @@
* CustomerUpdateForm representa el shape de datos del formulario de actualización de cliente.
* Es decir, los campos que se muestran en el formulario y que el usuario puede editar.
*
* Este shape es específico para la UI y no tiene por qué coincidir
* Es específico de la UI y no tiene por qué coincidir
* con el shape del dominio ni con el de la API.
*
* Debe cumplir las siguientes reglas:

View File

@ -15,33 +15,33 @@ import { z } from "zod/v4";
*/
export const CustomerUpdateFormSchema = z.object({
reference: z.string().optional().or(z.literal("")),
reference: z.string(),
isCompany: z.boolean(),
name: z.string().min(1, "El nombre es obligatorio"),
tradeName: z.string().optional().or(z.literal("")),
tradeName: z.string(),
tin: z.string(),
defaultTaxes: z.array(z.string()),
street: z.string().optional().or(z.literal("")),
street2: z.string().optional().or(z.literal("")),
city: z.string().optional().or(z.literal("")),
province: z.string().optional().or(z.literal("")),
postalCode: z.string().optional().or(z.literal("")),
country: z.string().min(1, "El país es obligatorio").optional().or(z.literal("")),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postalCode: z.string(),
country: z.string().min(1, "El país es obligatorio"),
primaryEmail: z.email("Email inválido").optional().or(z.literal("")),
secondaryEmail: z.email("Email inválido").optional().or(z.literal("")),
primaryEmail: z.email("Email inválido"),
secondaryEmail: z.email("Email inválido"),
primaryPhone: z.string().optional().or(z.literal("")),
secondaryPhone: z.string().optional().or(z.literal("")),
primaryMobile: z.string().optional().or(z.literal("")),
secondaryMobile: z.string().optional().or(z.literal("")),
primaryPhone: z.string(),
secondaryPhone: z.string(),
primaryMobile: z.string(),
secondaryMobile: z.string(),
fax: z.string().optional().or(z.literal("")),
website: z.url("URL inválida").optional().or(z.literal("")),
fax: z.string(),
website: z.url("URL inválida"),
legalRecord: z.string().optional().or(z.literal("")),
legalRecord: z.string(),
languageCode: z.string().min(1, "El idioma es obligatorio"),
currencyCode: z.string().min(1, "La moneda es obligatoria"),

View File

@ -1,12 +1,11 @@
import type { CustomerUpdateForm } from "./customer-update-form.entity";
/**
* CustomerUpdatePatch es un tipo que representa un objeto con las mismas
* propiedades que CustomerUpdateForm, pero todas ellas son opcionales.
* CustomerUpdatePatch representa los cambios que se van a aplicar a un cliente.
* Se representa con las mismas propiedades que CustomerUpdateForm,
* pero todas ellas son opcionales.
*
* Esto es útil para representar los datos que se van a enviar a la API para actualizar un cliente,
* ya que en una actualización parcial (PATCH) no es necesario enviar todos los campos,
* sino solo aquellos que se quieren modificar.
* A la API solo hay que enviar los campos que han cambiado.
*
* Reglas:
* - debe ser un Partial de CustomerUpdateForm

View File

@ -5,13 +5,17 @@ import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from "./customer-contact-fields";
type CustomerFormProps = {
type CustomerUpdateEditorFormProps = {
formId: string;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
className?: string;
};
export const CustomerEditForm = ({ formId, onSubmit, className }: CustomerFormProps) => {
export const CustomerUpdateEditorForm = ({
formId,
onSubmit,
className,
}: CustomerUpdateEditorFormProps) => {
return (
<form id={formId} noValidate onSubmit={onSubmit}>
<section className={cn("space-y-12 p-6", className)}>

View File

@ -6,7 +6,7 @@ import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { useCustomerUpdatePageController } from "../../controllers";
import { CustomerEditorSkeleton } from "../components";
import { CustomerEditForm } from "../editor";
import { CustomerUpdateEditorForm } from "../editor";
export const CustomerUpdatePage = () => {
const { t } = useTranslation();
@ -105,7 +105,7 @@ export const CustomerUpdatePage = () => {
)}
<FormProvider {...form}>
<CustomerEditForm
<CustomerUpdateEditorForm
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6 " // para que el botón del header pueda hacer submit
formId={formId}
onSubmit={onSubmit}

View File

@ -0,0 +1,20 @@
import { pickFormDirtyValues } from "@erp/core/client";
import type { FieldNamesMarkedBoolean } from "react-hook-form";
import type { CustomerUpdateForm, CustomerUpdatePatch } from "../entities";
/**
* Construye un parche de actualización de cliente a partir de los datos
* del formulario y los campos sucios.
*
* @param formData
* @param dirtyFields
* @returns
*/
export const buildCustomerUpdatePatch = (
formData: CustomerUpdateForm,
dirtyFields: FieldNamesMarkedBoolean<CustomerUpdateForm>
): CustomerUpdatePatch => {
return pickFormDirtyValues(formData, dirtyFields) as CustomerUpdatePatch;
};

View File

@ -1,7 +1,5 @@
import { pickFormDirtyValues } from "@erp/core/client";
import type { FieldNamesMarkedBoolean } from "react-hook-form";
import type { CustomerUpdateForm, CustomerUpdatePatch } from "../entities";
import type { UpdateCustomerByIdParams } from "../../shared";
import type { CustomerUpdatePatch } from "../entities";
/**
* Construye un parche de actualización de cliente a partir de los datos
@ -15,15 +13,22 @@ import type { CustomerUpdateForm, CustomerUpdatePatch } from "../entities";
* - no debe tener transformaciones ni campos adicionales, solo los que vienen del
* formulario y están sucios.
*
* @param formData
* @param dirtyFields
* @param id - El ID del cliente que se va a actualizar.
* @param patchData - Los datos del parche de actualización.
* @returns Un objeto que se puede enviar a la API para actualizar un cliente,
* con solo los campos que han cambiado.
*/
export const buildCustomerUpdatePatch = (
formData: CustomerUpdateForm,
dirtyFields: FieldNamesMarkedBoolean<CustomerUpdateForm>
): CustomerUpdatePatch => {
return pickFormDirtyValues(formData, dirtyFields) as CustomerUpdatePatch;
export const buildUpdateCustomerByIdParams = (
id: string,
patchData: CustomerUpdatePatch
): UpdateCustomerByIdParams => {
if (!id) {
throw new Error("customerId is required");
}
return {
id,
data: patchData,
} as UpdateCustomerByIdParams;
};

View File

@ -1 +1,2 @@
export * from "./build-customer-update-patch";
export * from "./build-customer.update-patch";
export * from "./build-update-customer-by-id-params";