diff --git a/modules/customers/src/common/dto/response/update-customer-by-id.response.dto.ts b/modules/customers/src/common/dto/response/update-customer-by-id.response.dto.ts index 81b9c3bb..a81d0940 100644 --- a/modules/customers/src/common/dto/response/update-customer-by-id.response.dto.ts +++ b/modules/customers/src/common/dto/response/update-customer-by-id.response.dto.ts @@ -1,40 +1,7 @@ -import { MetadataSchema } from "@erp/core"; -import { z } from "zod/v4"; +import { + type GetCustomerByIdResponseDTO, + GetCustomerByIdResponseSchema, +} from "./get-customer-by-id.response.dto"; -export const UpdateCustomerByIdResponseSchema = z.object({ - id: z.uuid(), - company_id: z.uuid(), - reference: z.string(), - - is_company: z.string(), - name: z.string(), - trade_name: z.string(), - tin: z.string(), - - street: z.string(), - street2: z.string(), - city: z.string(), - province: z.string(), - postal_code: z.string(), - country: z.string(), - - email_primary: z.string(), - email_secondary: z.string(), - phone_primary: z.string(), - phone_secondary: z.string(), - mobile_primary: z.string(), - mobile_secondary: z.string(), - - fax: z.string(), - website: z.string(), - - legal_record: z.string(), - - default_taxes: z.array(z.string()), - language_code: z.string(), - currency_code: z.string(), - - metadata: MetadataSchema.optional(), -}); - -export type UpdateCustomerByIdResponseDTO = z.infer; +export const UpdateCustomerByIdResponseSchema = GetCustomerByIdResponseSchema; +export type UpdateCustomerByIdResponseDTO = GetCustomerByIdResponseDTO; diff --git a/modules/customers/src/web/_archived/constants/customer.constants.ts b/modules/customers/src/web/_archived/constants/customer.constants.ts deleted file mode 100644 index 180eaed4..00000000 --- a/modules/customers/src/web/_archived/constants/customer.constants.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const COUNTRY_OPTIONS = [ - { value: "es", label: "España" }, - { value: "fr", label: "Francia" }, - { value: "de", label: "Alemania" }, - { value: "it", label: "Italia" }, - { value: "pt", label: "Portugal" }, - { value: "us", label: "Estados Unidos" }, - { value: "mx", label: "México" }, - { value: "ar", label: "Argentina" }, -] as const; - -export const LANGUAGE_OPTIONS = [ - { value: "es", label: "Español" }, - { value: "en", label: "Inglés" }, - { value: "fr", label: "Francés" }, - { value: "de", label: "Alemán" }, - { value: "it", label: "Italiano" }, - { value: "pt", label: "Portugués" }, -] as const; - -export const CURRENCY_OPTIONS = [ - { value: "EUR", label: "Euro" }, - { value: "USD", label: "Dólar estadounidense" }, - { value: "GBP", label: "Libra esterlina" }, - { value: "ARS", label: "Peso argentino" }, - { value: "MXN", label: "Peso mexicano" }, - { value: "JPY", label: "Yen japonés" }, -] as const; diff --git a/modules/customers/src/web/_archived/constants/index.ts b/modules/customers/src/web/_archived/constants/index.ts deleted file mode 100644 index 1186e595..00000000 --- a/modules/customers/src/web/_archived/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./customer.constants"; diff --git a/modules/customers/src/web/_archived/hooks/use-create-customer-mutation.ts b/modules/customers/src/web/_archived/hooks/use-create-customer-mutation.ts index c4e0559b..f149553b 100644 --- a/modules/customers/src/web/_archived/hooks/use-create-customer-mutation.ts +++ b/modules/customers/src/web/_archived/hooks/use-create-customer-mutation.ts @@ -3,7 +3,7 @@ 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/toValidationErrors"; +import { toValidationErrors } from "../../shared/hooks/to-validation-errors"; import type { Customer, CustomerFormData } from "../schemas"; import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query"; diff --git a/modules/customers/src/web/_archived/hooks/use-update-customer-mutation.ts b/modules/customers/src/web/_archived/hooks/use-update-customer-mutation.ts index ba7278ed..08050c71 100644 --- a/modules/customers/src/web/_archived/hooks/use-update-customer-mutation.ts +++ b/modules/customers/src/web/_archived/hooks/use-update-customer-mutation.ts @@ -3,7 +3,7 @@ import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { UpdateCustomerByIdRequestSchema } from "../../../common"; -import { toValidationErrors } from "../../shared/hooks/toValidationErrors"; +import { toValidationErrors } from "../../shared/hooks/to-validation-errors"; import type { Customer, CustomerFormData } from "../schemas"; import { diff --git a/modules/customers/src/web/create/controllers/index.ts b/modules/customers/src/web/create/controllers/index.ts new file mode 100644 index 00000000..debbf28e --- /dev/null +++ b/modules/customers/src/web/create/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-customer-update-page.controller"; diff --git a/modules/customers/src/web/create/controllers/use-customer-create-page.controller.ts b/modules/customers/src/web/create/controllers/use-customer-create-page.controller.ts new file mode 100644 index 00000000..32ed5660 --- /dev/null +++ b/modules/customers/src/web/create/controllers/use-customer-create-page.controller.ts @@ -0,0 +1,120 @@ +import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client"; +import { useHookForm } from "@erp/core/hooks"; +import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; +import { useId } from "react"; +import { type FieldErrors, FormProvider } from "react-hook-form"; + +import { useTranslation } from "../../i18n"; +import type { Customer } from "../../shared"; +import { type CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../types"; + +export interface UseCustomerCreateControllerOptions { + onCreated?(created: Customer): void; + successToasts?: boolean; // mostrar o no toast automáticcamente + + onError?(error: Error, patchData: ReturnType): void; + errorToasts?: boolean; // mostrar o no toast automáticcamente +} + +export const useCustomerCreateController = ( + customerId?: string, + options?: UseCustomerCreateControllerOptions +) => { + const { t } = useTranslation(); + const formId = useId(); // id único por instancia + + // 1) Estado de creación (mutación) + const { + mutateAsync, + isPending, + isError: isCreateError, + error: createError, + } = useCustomerCreateMutation(); + + // 2) Form hook + const form = useHookForm({ + resolverSchema: CustomerFormSchema, + initialValues: defaultCustomerFormData, + disabled: isPending, + }); + + /** Handlers */ + + const resetForm = () => form.reset(defaultCustomerFormData); + + // Versión sincronizada + const submitHandler = form.handleSubmit( + async (formData) => { + if (!customerId) { + showErrorToast(t("pages.create.error.title"), "Falta el ID del cliente"); + return; + } + + const { dirtyFields } = form.formState; + if (!formHasAnyDirty(dirtyFields)) { + showWarningToast(t("pages.create.error.no_changes"), "No hay cambios para guardar"); + return; + } + + const patchData = pickFormDirtyValues(formData, dirtyFields); + const previousData = customerData; + + try { + // Enviamos cambios al servidor + const created = await mutateAsync({ id: customerId, data: patchData }); + + // 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 }); + + if (options?.successToasts !== false) { + showSuccessToast( + t("pages.create.success.title", "Cliente modificado"), + t("pages.create.success.message", "Se ha modificado correctamente.") + ); + } + options?.onCreated?.(created); + } catch (error: any) { + // Algo ha fallado -> revertimos cambios + form.reset(previousData ?? defaultCustomerFormData); + if (options?.errorToasts !== false) { + showErrorToast(t("pages.create.error.title"), error.message); + } + options?.onError?.(error, patchData); + } + }, + (errors: FieldErrors) => { + const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined; + if (firstKey) document.querySelector(`[name="${String(firstKey)}"]`)?.focus(); + + showWarningToast( + t("forms.validation.title", "Revisa los campos"), + t("forms.validation.message", "Hay errores de validación en el formulario.") + ); + } + ); + + // Evento onSubmit ya preparado para el
+ const onSubmit = (event: React.FormEvent) => { + 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 + isPending, + isCreateError, + createError, + + // Por comodidad + FormProvider, + }; +}; diff --git a/modules/customers/src/web/create/types/index.ts b/modules/customers/src/web/create/types/index.ts new file mode 100644 index 00000000..eea524d6 --- /dev/null +++ b/modules/customers/src/web/create/types/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/modules/customers/src/web/create/types/types.ts b/modules/customers/src/web/create/types/types.ts new file mode 100644 index 00000000..97eb0ddb --- /dev/null +++ b/modules/customers/src/web/create/types/types.ts @@ -0,0 +1,89 @@ +import { z } from "zod/v4"; + +export const CustomerFormSchema = z.object({ + reference: z.string().optional(), + + is_company: z.string().default("true"), + name: z + .string({ + error: "El nombre es obligatorio", + }) + .min(1, "El nombre no puede estar vacío"), + trade_name: z.string().optional(), + tin: z.string().optional(), + default_taxes: z.array(z.string()).default([]), + + street: z.string().optional(), + street2: z.string().optional(), + city: z.string().optional(), + province: z.string().optional(), + postal_code: z.string().optional(), + country: z + .string({ + error: "El país es obligatorio", + }) + .min(1, "El país no puede estar vacío") + .toLowerCase() // asegura minúsculas + .default("es"), + + email_primary: z.string().optional(), + email_secondary: z.string().optional(), + phone_primary: z.string().optional(), + phone_secondary: z.string().optional(), + mobile_primary: z.string().optional(), + mobile_secondary: z.string().optional(), + + fax: z.string().optional(), + website: z.string().optional(), + + legal_record: z.string().optional(), + + language_code: z + .string({ + error: "El idioma es obligatorio", + }) + .min(1, "Debe indicar un idioma") + .toUpperCase() // asegura mayúsculas + .default("es"), + + currency_code: z + .string({ + error: "La moneda es obligatoria", + }) + .min(1, "La moneda no puede estar vacía") + .toUpperCase() // asegura mayúsculas + .default("EUR"), +}); + +export type CustomerFormData = z.infer; + +export const defaultCustomerFormData: CustomerFormData = { + reference: "", + + is_company: "", + name: "", + trade_name: "", + tin: "", + default_taxes: [], + + street: "", + street2: "", + city: "", + province: "", + postal_code: "", + country: "es", + + email_primary: "", + email_secondary: "", + phone_primary: "", + phone_secondary: "", + mobile_primary: "", + mobile_secondary: "", + + fax: "", + website: "", + + legal_record: "", + language_code: "", + currency_code: "", +}; diff --git a/modules/customers/src/web/create/ui/components/index.ts b/modules/customers/src/web/create/ui/components/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customers/src/web/create/ui/editor/index.ts b/modules/customers/src/web/create/ui/editor/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customers/src/web/create/ui/forms/index.ts b/modules/customers/src/web/create/ui/forms/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customers/src/web/create/ui/index.ts b/modules/customers/src/web/create/ui/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/modules/customers/src/web/create/ui/pages/customer-create-page.tsx b/modules/customers/src/web/create/ui/pages/customer-create-page.tsx new file mode 100644 index 00000000..c141fed5 --- /dev/null +++ b/modules/customers/src/web/create/ui/pages/customer-create-page.tsx @@ -0,0 +1,115 @@ +import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components"; +import { CreateCommitButtonGroup, UnsavedChangesProvider, useUrlParamId } from "@erp/core/hooks"; +import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; + +import { useTranslation } from "../../../i18n"; +import { useCustomerCreateController } from "../../controllers"; +import { CustomerEditorSkeleton } from "../components"; +import { CustomerEditForm } from "../editor"; + +export const CustomerCreatePage = () => { + const initialCustomerId = useUrlParamId(); + const { t } = useTranslation(); + + const { + form, + formId, + onSubmit, + resetForm, + + customerData, + isLoading, + isLoadError, + loadError, + + isUpdating, + isCreateError, + createError, + + FormProvider, + } = useCustomerCreateController(initialCustomerId, {}); + + if (isLoading) { + return ; + } + + if (isLoadError) { + return ( + <> + + + +
+ +
+
+ + ); + } + + if (!customerData) + return ( + <> + + + + + ); + + return ( + + + + } + title={t("pages.create.title")} + /> + + + {/* Alerta de error de actualización (si ha fallado el último intento) */} + {isCreateError && ( + + )} + + + + + + + ); +}; diff --git a/modules/customers/src/web/create/ui/pages/index.ts b/modules/customers/src/web/create/ui/pages/index.ts new file mode 100644 index 00000000..375deba4 --- /dev/null +++ b/modules/customers/src/web/create/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./customer-create-page"; diff --git a/modules/customers/src/web/shared/adapters/customer-to-list-row-patch.adapter.ts b/modules/customers/src/web/shared/adapters/customer-to-list-row-patch.adapter.ts new file mode 100644 index 00000000..a7902ac4 --- /dev/null +++ b/modules/customers/src/web/shared/adapters/customer-to-list-row-patch.adapter.ts @@ -0,0 +1,38 @@ +import type { Customer } from "../entities/customer.entity"; +import type { CustomerListRow } from "../entities/customer-list-row.entity"; + +export type CustomerListRowPatch = Pick & Partial; + +export const CustomerToListRowPatchAdapter = { + fromCustomer(customer: Customer): CustomerListRowPatch { + return { + id: customer.id, + status: customer.status, + reference: customer.reference, + + isCompany: customer.isCompany, + name: customer.name, + tradeName: customer.tradeName, + tin: customer.tin, + + street: customer.street, + street2: customer.street2, + city: customer.city, + province: customer.province, + postalCode: customer.postalCode, + country: customer.country, + + primaryEmail: customer.primaryEmail, + secondaryEmail: customer.secondaryEmail, + primaryPhone: customer.primaryPhone, + secondaryPhone: customer.secondaryPhone, + primaryMobile: customer.primaryMobile, + secondaryMobile: customer.secondaryMobile, + fax: customer.fax, + website: customer.website, + + languageCode: customer.languageCode, + currencyCode: customer.currencyCode, + }; + }, +}; diff --git a/modules/customers/src/web/shared/adapters/get-customer-by-id.adapter.ts b/modules/customers/src/web/shared/adapters/get-customer-by-id.adapter.ts index 9e52c540..cc78a475 100644 --- a/modules/customers/src/web/shared/adapters/get-customer-by-id.adapter.ts +++ b/modules/customers/src/web/shared/adapters/get-customer-by-id.adapter.ts @@ -1,9 +1,15 @@ -import type { GetCustomerByIdResponseDTO } from "../../../common"; +import type { CustomerCreationResponseDTO } from "@erp/customers/common"; + +import type { CustomerGetOutput, CustomerUpdateOutput } from "../api"; import type { Customer } from "../entities"; export const GetCustomerByIdAdapter = { - fromDTO(dto: GetCustomerByIdResponseDTO, context?: unknown): Customer { - const taxesAdapter = (taxes: string) => taxes.split(";").filter((item) => item !== "#") || []; + fromDTO( + dto: CustomerGetOutput | CustomerCreationResponseDTO | CustomerUpdateOutput, + context?: unknown + ): Customer { + const taxesAdapter = (taxes: string) => + taxes.split(";").filter((item) => item !== "#" && item.trim() !== ""); const defaultTaxes = taxesAdapter(dto.default_taxes); @@ -12,7 +18,7 @@ export const GetCustomerByIdAdapter = { companyId: dto.company_id, reference: dto.reference, - isCompany: dto.is_company, + isCompany: dto.is_company === "1", name: dto.name, tradeName: dto.trade_name, tin: dto.tin, diff --git a/modules/customers/src/web/shared/adapters/index.ts b/modules/customers/src/web/shared/adapters/index.ts index bfa86df9..de67a4cd 100644 --- a/modules/customers/src/web/shared/adapters/index.ts +++ b/modules/customers/src/web/shared/adapters/index.ts @@ -1,2 +1,3 @@ +export * from "./customer-to-list-row-patch.adapter"; export * from "./get-customer-by-id.adapter"; export * from "./list-customers.adapter"; diff --git a/modules/customers/src/web/shared/adapters/list-customers.adapter.ts b/modules/customers/src/web/shared/adapters/list-customers.adapter.ts index d2e72322..e0cd8dd8 100644 --- a/modules/customers/src/web/shared/adapters/list-customers.adapter.ts +++ b/modules/customers/src/web/shared/adapters/list-customers.adapter.ts @@ -1,12 +1,9 @@ -import type { ListCustomersResponseDTO } from "../../../common"; +import type { CustomerListOutput } from "../api"; import type { CustomerList, CustomerListRow } from "../entities"; -type ListCustomersItemDTO = ListCustomersResponseDTO["items"][number]; - export const ListCustomersAdapter = { - fromDTO(pageDto: ListCustomersResponseDTO, context?: unknown): CustomerList { + fromDTO(pageDto: CustomerListOutput, context?: unknown): CustomerList { return { - //...pageDto, page: pageDto.page, per_page: pageDto.per_page, total_pages: pageDto.total_pages, @@ -16,13 +13,10 @@ export const ListCustomersAdapter = { }, }; -const ListCustomersRowAdapter = { - fromDTO(rowDto: ListCustomersItemDTO, context?: unknown): CustomerListRow { - /*return { - ...rowDto, - is_company: rowDto.is_company === "1", - };*/ +type CustomerListItemOutput = CustomerListOutput["items"][number]; +const ListCustomersRowAdapter = { + fromDTO(rowDto: CustomerListItemOutput, context?: unknown): CustomerListRow { return { id: rowDto.id, companyId: rowDto.company_id, diff --git a/modules/customers/src/web/shared/api/create-customer.api.ts b/modules/customers/src/web/shared/api/create-customer.api.ts new file mode 100644 index 00000000..91aa7c1f --- /dev/null +++ b/modules/customers/src/web/shared/api/create-customer.api.ts @@ -0,0 +1,14 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { CreateCustomerRequestDTO, CustomerCreationResponseDTO } from "../../../common"; + +export type CustomerCreateInput = CreateCustomerRequestDTO; +export type CustomerCreateOutput = CustomerCreationResponseDTO; + +export function createCustomer(dataSource: IDataSource, id: string, data: CustomerCreateInput) { + if (!id) throw new Error("customerId is required"); + return dataSource.createOne("customers", { + ...data, + id, + }); +} diff --git a/modules/customers/src/web/shared/api/delete-customer-by-id.api.ts b/modules/customers/src/web/shared/api/delete-customer-by-id.api.ts new file mode 100644 index 00000000..4327c3dc --- /dev/null +++ b/modules/customers/src/web/shared/api/delete-customer-by-id.api.ts @@ -0,0 +1,8 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { DeleteCustomerByIdRequestDTO } from "../../../common"; +export type CustomerDeleteInput = DeleteCustomerByIdRequestDTO; + +export function deleteCustomerById(dataSource: IDataSource, id: string, signal: AbortSignal) { + return dataSource.deleteOne("customers", id, { signal }); +} diff --git a/modules/customers/src/web/shared/api/get-customer-by-id.api.ts b/modules/customers/src/web/shared/api/get-customer-by-id.api.ts index 1f188c5e..8371b9a1 100644 --- a/modules/customers/src/web/shared/api/get-customer-by-id.api.ts +++ b/modules/customers/src/web/shared/api/get-customer-by-id.api.ts @@ -2,8 +2,8 @@ import type { IDataSource } from "@erp/core/client"; import type { GetCustomerByIdResponseDTO } from "../../../common"; -export async function getCustomerById(dataSource: IDataSource, signal: AbortSignal, id?: string) { - if (!id) throw new Error("customerId is required"); - const response = dataSource.getOne("customers", id, { signal }); - return response; +export type CustomerGetOutput = GetCustomerByIdResponseDTO; + +export function getCustomerById(dataSource: IDataSource, id: string, signal: AbortSignal) { + return dataSource.getOne("customers", id, { signal }); } diff --git a/modules/customers/src/web/shared/api/index.ts b/modules/customers/src/web/shared/api/index.ts index e91c5b1d..8febd039 100644 --- a/modules/customers/src/web/shared/api/index.ts +++ b/modules/customers/src/web/shared/api/index.ts @@ -1,2 +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 "./update-customer-by-id.api"; diff --git a/modules/customers/src/web/shared/api/list-customers.api.ts b/modules/customers/src/web/shared/api/list-customers.api.ts index 446c1c5a..fe245aeb 100644 --- a/modules/customers/src/web/shared/api/list-customers.api.ts +++ b/modules/customers/src/web/shared/api/list-customers.api.ts @@ -3,15 +3,15 @@ import type { IDataSource } from "@erp/core/client"; import type { ListCustomersResponseDTO } from "../../../common"; -export async function getListCustomers( +export type CustomerListOutput = ListCustomersResponseDTO; + +export function getListCustomers( dataSource: IDataSource, - signal: AbortSignal, - criteria: CriteriaDTO + criteria: CriteriaDTO, + signal: AbortSignal ) { - const response = dataSource.getList("customers", { + return dataSource.getList("customers", { signal, ...criteria, }); - - return response; } diff --git a/modules/customers/src/web/shared/api/update-customer-by-id.api.ts b/modules/customers/src/web/shared/api/update-customer-by-id.api.ts new file mode 100644 index 00000000..884ff440 --- /dev/null +++ b/modules/customers/src/web/shared/api/update-customer-by-id.api.ts @@ -0,0 +1,11 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { UpdateCustomerByIdRequestDTO, UpdateCustomerByIdResponseDTO } from "../../../common"; + +export type CustomerUpdateInput = UpdateCustomerByIdRequestDTO; +export type CustomerUpdateOutput = UpdateCustomerByIdResponseDTO; + +export function updateCustomerById(dataSource: IDataSource, id: string, data: CustomerUpdateInput) { + if (!id) throw new Error("customerId is required"); + return dataSource.updateOne("customers", id, data); +} diff --git a/modules/customers/src/web/shared/entities/customer.entity.ts b/modules/customers/src/web/shared/entities/customer.entity.ts index ffca4c01..84b7479c 100644 --- a/modules/customers/src/web/shared/entities/customer.entity.ts +++ b/modules/customers/src/web/shared/entities/customer.entity.ts @@ -1,9 +1,10 @@ export interface Customer { id: string; companyId: string; + status: string; reference: string; - isCompany: string; + isCompany: boolean; name: string; tradeName: string; tin: string; @@ -28,7 +29,7 @@ export interface Customer { legalRecord: string; defaultTaxes: string[]; - status: string; + languageCode: string; currencyCode: string; } diff --git a/modules/customers/src/web/shared/hooks/customer-cache-strategy.ts b/modules/customers/src/web/shared/hooks/customer-cache-strategy.ts new file mode 100644 index 00000000..b91ca85d --- /dev/null +++ b/modules/customers/src/web/shared/hooks/customer-cache-strategy.ts @@ -0,0 +1,149 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; + +import { CustomerToListRowPatchAdapter } from "../adapters"; +import type { Customer, CustomerList, CustomerListRow } from "../entities"; + +import { CUSTOMER_QUERY_KEY, LIST_CUSTOMERS_QUERY_KEY_PREFIX } from "./keys"; + +export interface CustomerListCacheSnapshot { + key: QueryKey; + page?: CustomerList; +} + +export interface DeleteCustomerCacheContext { + snapshots: CustomerListCacheSnapshot[]; +} + +export function cancelCustomerListQueries(queryClient: QueryClient) { + return queryClient.cancelQueries({ + queryKey: LIST_CUSTOMERS_QUERY_KEY_PREFIX, + }); +} + +export function invalidateCustomerListQueries(queryClient: QueryClient) { + return queryClient.invalidateQueries({ + queryKey: LIST_CUSTOMERS_QUERY_KEY_PREFIX, + }); +} + +export function invalidateCustomerDetailQuery(queryClient: QueryClient, customerId: string) { + return queryClient.invalidateQueries({ + queryKey: CUSTOMER_QUERY_KEY(customerId), + exact: true, + }); +} + +export function setCustomerDetailCache( + queryClient: QueryClient, + customerId: string, + customer: Customer +) { + queryClient.setQueryData(CUSTOMER_QUERY_KEY(customerId), customer); +} + +export function removeCustomerDetailCache(queryClient: QueryClient, customerId: string) { + queryClient.removeQueries({ + queryKey: CUSTOMER_QUERY_KEY(customerId), + exact: true, + }); +} + +export function getAllCustomerListQueryKeys(queryClient: QueryClient): QueryKey[] { + const entries = queryClient.getQueriesData({ + queryKey: LIST_CUSTOMERS_QUERY_KEY_PREFIX, + }); + + return entries.map(([key]) => key); +} + +export function upsertCustomerInListCaches( + queryClient: QueryClient, + customer: Pick & Partial +) { + const keys = getAllCustomerListQueryKeys(queryClient); + + for (const key of keys) { + const page = queryClient.getQueryData(key); + if (!page) continue; + + const index = page.items.findIndex((row) => row.id === customer.id); + if (index === -1) continue; + + const nextItems = page.items.slice(); + nextItems[index] = { + ...page.items[index], + ...customer, + }; + + queryClient.setQueryData(key, { + ...page, + items: nextItems, + }); + } +} + +export function removeCustomerFromListCaches( + queryClient: QueryClient, + customerId: string +): CustomerListCacheSnapshot[] { + const snapshots = getAllCustomerListQueryKeys(queryClient).map((key) => ({ + key, + page: queryClient.getQueryData(key), + })); + + for (const { key, page } of snapshots) { + if (!page) continue; + + queryClient.setQueryData(key, { + ...page, + items: page.items.filter((row) => row.id !== customerId), + total_items: Math.max(0, page.total_items - 1), + }); + } + + return snapshots; +} + +export function restoreCustomerListCaches( + queryClient: QueryClient, + snapshots: CustomerListCacheSnapshot[] +) { + for (const snapshot of snapshots) { + queryClient.setQueryData(snapshot.key, snapshot.page); + } +} + +export function syncCreatedCustomerCaches(queryClient: QueryClient, customer: Customer) { + setCustomerDetailCache(queryClient, customer.id, customer); + return invalidateCustomerListQueries(queryClient); +} + +export function syncUpdatedCustomerCaches(queryClient: QueryClient, customer: Customer) { + setCustomerDetailCache(queryClient, customer.id, customer); + upsertCustomerInListCaches(queryClient, CustomerToListRowPatchAdapter.fromCustomer(customer)); + + return invalidateCustomerListQueries(queryClient); +} + +export async function prepareDeleteCustomerOptimisticUpdate( + queryClient: QueryClient, + customerId: string +): Promise { + await cancelCustomerListQueries(queryClient); + const snapshots = removeCustomerFromListCaches(queryClient, customerId); + + return { snapshots }; +} + +export function rollbackDeleteCustomerOptimisticUpdate( + queryClient: QueryClient, + context?: DeleteCustomerCacheContext +) { + if (!context) return; + restoreCustomerListCaches(queryClient, context.snapshots); +} + +export function finalizeDeletedCustomerCaches(queryClient: QueryClient, customerId: string) { + removeCustomerDetailCache(queryClient, customerId); + return invalidateCustomerListQueries(queryClient); +} diff --git a/modules/customers/src/web/shared/hooks/keys.ts b/modules/customers/src/web/shared/hooks/keys.ts new file mode 100644 index 00000000..2c28b4a6 --- /dev/null +++ b/modules/customers/src/web/shared/hooks/keys.ts @@ -0,0 +1,30 @@ +import type { CriteriaDTO } from "@erp/core"; +import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria"; +import type { QueryKey } from "@tanstack/react-query"; + +export const LIST_CUSTOMERS_QUERY_KEY_PREFIX = ["customers"] as const; +export const LIST_CUSTOMERS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => + [ + ...LIST_CUSTOMERS_QUERY_KEY_PREFIX, + { + pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX, + pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE, + q: criteria?.q ?? "", + filters: criteria?.filters ?? [], + orderBy: criteria?.orderBy ?? "", + order: criteria?.order ?? "", + }, + ] as const; + +export const CUSTOMER_QUERY_KEY = (customerId?: string): QueryKey => [ + "customers:detail", + { + customerId, + }, +]; + +export const CUSTOMER_CREATE_KEY = ["customers", "create"] as const; + +export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const; + +export const CUSTOMER_DELETE_KEY = ["customers", "delete"] as const; diff --git a/modules/customers/src/web/shared/hooks/toValidationErrors.ts b/modules/customers/src/web/shared/hooks/to-validation-errors.ts similarity index 100% rename from modules/customers/src/web/shared/hooks/toValidationErrors.ts rename to modules/customers/src/web/shared/hooks/to-validation-errors.ts diff --git a/modules/customers/src/web/shared/hooks/use-customer-create-mutation.ts b/modules/customers/src/web/shared/hooks/use-customer-create-mutation.ts new file mode 100644 index 00000000..f41fad16 --- /dev/null +++ b/modules/customers/src/web/shared/hooks/use-customer-create-mutation.ts @@ -0,0 +1,51 @@ +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 { GetCustomerByIdAdapter } from "../adapters"; +import { type CustomerCreateInput, createCustomer } from "../api"; +import type { Customer } from "../entities"; + +import { + invalidateCustomerListQueries, + syncCreatedCustomerCaches, +} from "./customer-cache-strategy"; +import { CUSTOMER_CREATE_KEY } from "./keys"; +import { toValidationErrors } from "./to-validation-errors"; + +type CreateCustomerContext = {}; + +type CreateCustomerPayload = { + data: CustomerCreateInput; +}; + +export const useCustomerCreateMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + const schema = CreateCustomerRequestSchema; + + return useMutation({ + mutationKey: CUSTOMER_CREATE_KEY, + + mutationFn: async (payload) => { + const { data } = payload; + const id = UniqueID.generateNewID().toString(); + + const result = schema.safeParse(data); + if (!result.success) { + throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); + } + + const dto = await createCustomer(dataSource, id, data); + return GetCustomerByIdAdapter.fromDTO(dto); + }, + + onSuccess: (createdCustomer) => { + syncCreatedCustomerCaches(queryClient, createdCustomer); + }, + onSettled: async () => { + await invalidateCustomerListQueries(queryClient); + }, + }); +}; diff --git a/modules/customers/src/web/shared/hooks/use-customer-delete-mutation.ts b/modules/customers/src/web/shared/hooks/use-customer-delete-mutation.ts new file mode 100644 index 00000000..d9b3fbaf --- /dev/null +++ b/modules/customers/src/web/shared/hooks/use-customer-delete-mutation.ts @@ -0,0 +1,50 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + +import { deleteCustomerById } from "../api"; + +import { + type DeleteCustomerCacheContext, + finalizeDeletedCustomerCaches, + invalidateCustomerListQueries, + prepareDeleteCustomerOptimisticUpdate, + rollbackDeleteCustomerOptimisticUpdate, +} 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>({ + mutationKey: CUSTOMER_DELETE_KEY, + mutationFn: async ({ id }) => { + if (!id) { + throw new Error("customerId is required"); + } + + await deleteCustomerById(dataSource, id, new AbortController().signal); + return { id }; + }, + onMutate: async ({ id }) => { + return prepareDeleteCustomerOptimisticUpdate(queryClient, id); + }, + + onError: (_error, _variables, context) => { + rollbackDeleteCustomerOptimisticUpdate(queryClient, context); + }, + + onSuccess: ({ id }) => { + finalizeDeletedCustomerCaches(queryClient, id); + }, + onSettled: async () => { + await invalidateCustomerListQueries(queryClient); + }, + }); +}; diff --git a/modules/customers/src/web/shared/hooks/use-customer-get-query.ts b/modules/customers/src/web/shared/hooks/use-customer-get-query.ts index b324dbe2..d9c46c9c 100644 --- a/modules/customers/src/web/shared/hooks/use-customer-get-query.ts +++ b/modules/customers/src/web/shared/hooks/use-customer-get-query.ts @@ -1,52 +1,31 @@ import { useDataSource } from "@erp/core/hooks"; -import { - type DefaultError, - type QueryKey, - type UseQueryResult, - useQuery, -} from "@tanstack/react-query"; +import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { GetCustomerByIdAdapter } from "../adapters"; import { getCustomerById } from "../api"; import type { Customer } from "../entities"; -export const CUSTOMER_QUERY_KEY = (customerId?: string): QueryKey => [ - "customers:detail", - { - customerId, - }, -]; +import { CUSTOMER_QUERY_KEY } from "./keys"; -type CustomerQueryOptions = { +type CustomerGetQueryOptions = { enabled?: boolean; }; export const useCustomerGetQuery = ( customerId?: string, - options?: CustomerQueryOptions + options?: CustomerGetQueryOptions ): UseQueryResult => { + //const queryClient = useQueryClient(); const dataSource = useDataSource(); const enabled = options?.enabled ?? Boolean(customerId); return useQuery({ queryKey: CUSTOMER_QUERY_KEY(customerId), queryFn: async ({ signal }) => { - const dto = await getCustomerById(dataSource, signal, customerId); + const dto = await getCustomerById(dataSource, String(customerId), signal); return GetCustomerByIdAdapter.fromDTO(dto); }, enabled, - placeholderData: (previousData) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`) + //placeholderData <-- No mostrar datos de una tupla anterior mientras se carga nuevos datos }); }; - -/*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); -} -*/ diff --git a/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts b/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts index 8f229f8d..1a895b88 100644 --- a/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts +++ b/modules/customers/src/web/shared/hooks/use-customer-update-mutation.ts @@ -3,18 +3,22 @@ import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { UpdateCustomerByIdRequestSchema } from "../../../common"; -import type { Customer } from "../api"; -import type { CustomerData } from "../types"; +import { GetCustomerByIdAdapter } from "../adapters"; +import { type CustomerUpdateInput, updateCustomerById } from "../api"; +import type { Customer } from "../entities"; -import { toValidationErrors } from "./toValidationErrors"; - -export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const; +import { + invalidateCustomerListQueries, + syncUpdatedCustomerCaches, +} from "./customer-cache-strategy"; +import { CUSTOMER_UPDATE_KEY } from "./keys"; +import { toValidationErrors } from "./to-validation-errors"; type UpdateCustomerContext = {}; type UpdateCustomerPayload = { id: string; - data: Partial; + data: CustomerUpdateInput; }; export const useCustomerUpdateMutation = () => { @@ -36,26 +40,15 @@ export const useCustomerUpdateMutation = () => { throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); } - const updated = await dataSource.updateOne("customers", customerId, data); - return updated as Customer; + const dto = await updateCustomerById(dataSource, customerId, data); + return GetCustomerByIdAdapter.fromDTO(dto); }, - 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 }); + onSuccess: (updatedCustomer) => { + syncUpdatedCustomerCaches(queryClient, updatedCustomer); }, - - onSettled: () => { - // Refresca todos los listados - //invalidateCustomerListCache(queryClient); + onSettled: async () => { + await invalidateCustomerListQueries(queryClient); }, }); }; diff --git a/modules/customers/src/web/shared/hooks/use-list-customers-query.ts b/modules/customers/src/web/shared/hooks/use-list-customers-query.ts index a5adbdcf..af9403f4 100644 --- a/modules/customers/src/web/shared/hooks/use-list-customers-query.ts +++ b/modules/customers/src/web/shared/hooks/use-list-customers-query.ts @@ -1,30 +1,12 @@ import type { CriteriaDTO } from "@erp/core"; import { useDataSource } from "@erp/core/hooks"; -import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria"; -import { - type DefaultError, - type QueryClient, - type QueryKey, - type UseQueryResult, - useQuery, -} from "@tanstack/react-query"; +import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; import { ListCustomersAdapter } from "../adapters"; import { getListCustomers } from "../api"; -import type { CustomerList, CustomerListRow } from "../entities"; +import type { CustomerList } from "../entities"; -export const LIST_CUSTOMERS_QUERY_KEY_PREFIX = ["customers"] as const; -export const LIST_CUSTOMERS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [ - LIST_CUSTOMERS_QUERY_KEY_PREFIX, - { - pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX, - pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE, - q: criteria?.q ?? "", - filters: criteria?.filters ?? [], - orderBy: criteria?.orderBy ?? "", - order: criteria?.order ?? "", - }, -]; +import { LIST_CUSTOMERS_QUERY_KEY } from "./keys"; type ListCustomersQueryOptions = { enabled?: boolean; @@ -41,74 +23,10 @@ export const useListCustomersQuery = ( return useQuery({ queryKey: LIST_CUSTOMERS_QUERY_KEY(criteria), queryFn: async ({ signal }) => { - const dto = await getListCustomers(dataSource, signal, criteria); + const dto = await getListCustomers(dataSource, criteria as CriteriaDTO, signal); return ListCustomersAdapter.fromDTO(dto); }, enabled, placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria }); }; - -export function cancelListCustomersQueries(qc: QueryClient) { - return qc.cancelQueries({ queryKey: LIST_CUSTOMERS_QUERY_KEY_PREFIX }); -} - -export function invalidateListCustomersQueries(qc: QueryClient) { - return qc.invalidateQueries({ queryKey: LIST_CUSTOMERS_QUERY_KEY_PREFIX }); -} - -export function getAllListCustomersQueryKeys(qc: QueryClient): QueryKey[] { - const entries = qc.getQueriesData({ - queryKey: LIST_CUSTOMERS_QUERY_KEY_PREFIX, - }); - - return entries.map(([key]) => key); -} - -export function upsertCustomerInListCustomersCaches( - qc: QueryClient, - customer: Pick & Partial -) { - const keys = getAllListCustomersQueryKeys(qc); - - for (const key of keys) { - const page = qc.getQueryData(key); - if (!page) continue; - - const index = page.items.findIndex((row) => row.id === customer.id); - if (index === -1) continue; - - const nextItems = page.items.slice(); - nextItems[index] = { - ...page.items[index], - ...customer, - }; - - qc.setQueryData(key, { - ...page, - items: nextItems, - }); - } -} - -export function removeCustomerFromListCustomersCaches( - qc: QueryClient, - customerId: string -): Array<{ key: QueryKey; page?: CustomerList }> { - const snapshots = getAllListCustomersQueryKeys(qc).map((key) => ({ - key, - page: qc.getQueryData(key), - })); - - for (const { key, page } of snapshots) { - if (!page) continue; - - qc.setQueryData(key, { - ...page, - items: page.items.filter((row) => row.id !== customerId), - total_items: Math.max(0, page.total_items - 1), - }); - } - - return snapshots; -} diff --git a/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts b/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts index 3c536d06..bc46e7b1 100644 --- a/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts +++ b/modules/customers/src/web/update/controllers/use-customer-update-page.controller.ts @@ -4,14 +4,9 @@ import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui import { useEffect, useId, useMemo } from "react"; import { type FieldErrors, FormProvider } from "react-hook-form"; -import { - type Customer, - CustomerFormSchema, - defaultCustomerFormData, -} from "../../_archived/schemas"; import { useTranslation } from "../../i18n"; -import { useCustomerGetQuery, useCustomerUpdateMutation } from "../../shared"; -import type { CustomerFormData } from "../types"; +import { type Customer, useCustomerGetQuery, useCustomerUpdateMutation } from "../../shared"; +import { type CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../types"; export interface UseCustomerUpdateControllerOptions { onUpdated?(updated: Customer): void; @@ -56,7 +51,10 @@ export const useCustomerUpdateController = ( /** Reiniciar el form al recibir datos */ useEffect(() => { // keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render. - if (customerData) form.reset(customerData, { keepDirty: false }); + if (customerData) { + console.log("Reseteando form con datos del cliente:", customerData); + form.reset(customerData, { keepDirty: false }); + } }, [customerData, form]); /** Handlers */ diff --git a/modules/customers/src/web/update/hooks/index.ts b/modules/customers/src/web/update/hooks/index.ts deleted file mode 100644 index b4938e3b..00000000 --- a/modules/customers/src/web/update/hooks/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./use-customer-form"; -export * from "./use-customer-update-mutation"; diff --git a/modules/customers/src/web/update/hooks/use-customer-form.ts b/modules/customers/src/web/update/hooks/use-customer-form.ts deleted file mode 100644 index 293f2430..00000000 --- a/modules/customers/src/web/update/hooks/use-customer-form.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useHookForm } from "@erp/core/hooks"; -import { useEffect, useMemo } from "react"; - -import type { Customer } from "../api"; - -function useCustomerForm(customerData: Customer | undefined, isDisabled: boolean) { - const initialValues = useMemo(() => customerData ?? defaultCustomerFormData, [customerData]); - - const form = useHookForm()({ - resolverSchema: CustomerFormSchema, - initialValues, - disabled: isDisabled, - }); - - useEffect(() => { - if (customerData) form.reset(customerData); - }, [customerData, form]); - - const resetForm = () => form.reset(customerData ?? defaultCustomerFormData); - - return { form, resetForm }; -} diff --git a/modules/customers/src/web/update/types/types.ts b/modules/customers/src/web/update/types/types.ts index abda9345..97eb0ddb 100644 --- a/modules/customers/src/web/update/types/types.ts +++ b/modules/customers/src/web/update/types/types.ts @@ -60,11 +60,11 @@ export type CustomerFormData = z.infer; export const defaultCustomerFormData: CustomerFormData = { reference: "", - is_company: "true", + is_company: "", name: "", trade_name: "", tin: "", - default_taxes: ["iva_21"], + default_taxes: [], street: "", street2: "", @@ -84,6 +84,6 @@ export const defaultCustomerFormData: CustomerFormData = { website: "", legal_record: "", - language_code: "es", - currency_code: "EUR", + language_code: "", + currency_code: "", }; diff --git a/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx b/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx index 2d16ba23..d87a1509 100644 --- a/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx +++ b/modules/customers/src/web/update/ui/editor/customer-additional-config-fields.tsx @@ -30,7 +30,6 @@ export const CustomerAdditionalConfigFields = ({