diff --git a/modules/core/src/web/lib/helpers/form-utils.ts b/modules/core/src/web/lib/helpers/form-utils.ts index 559e797a..bc85ba3e 100644 --- a/modules/core/src/web/lib/helpers/form-utils.ts +++ b/modules/core/src/web/lib/helpers/form-utils.ts @@ -9,7 +9,7 @@ export function pickFormDirtyValues>( const result: Partial = {}; for (const key in dirtyFields) { - if (!Object.prototype.hasOwnProperty.call(dirtyFields, key)) continue; + if (!Object.hasOwn(dirtyFields, key)) continue; const isDirty = dirtyFields[key]; const value = values[key]; diff --git a/modules/customers/src/web/create/adapters/customer-to-customer-create-form.adapter.ts b/modules/customers/src/web/create/adapters/customer-to-customer-create-form.adapter.ts new file mode 100644 index 00000000..a29b64fe --- /dev/null +++ b/modules/customers/src/web/create/adapters/customer-to-customer-create-form.adapter.ts @@ -0,0 +1,47 @@ +import type { Customer } from "../../shared"; +import type { CustomerCreateForm } from "../entities"; + +/** + * Mapea un cliente a un formulario de creació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 creación. + * + * @param customer: El cliente que se va a adaptar al formulario de creación. + * @returns Un objeto con el shape de CustomerCreateForm, + * con los datos del cliente adaptados para mostrar + * en el formulario de creación. + */ + +export const mapCustomerToCustomerCreateForm = (customer: Customer): CustomerCreateForm => { + return { + reference: customer.reference ?? "", + isCompany: customer.isCompany, + name: customer.name ?? "", + tradeName: customer.tradeName ?? "", + tin: customer.tin ?? "", + + defaultTaxes: customer.defaultTaxes ?? [], + + street: customer.street ?? "", + street2: customer.street2 ?? "", + city: customer.city ?? "", + province: customer.province ?? "", + postalCode: customer.postalCode ?? "", + country: customer.country ?? "es", + + primaryEmail: customer.primaryEmail ?? "", + secondaryEmail: customer.secondaryEmail ?? "", + primaryPhone: customer.primaryPhone ?? "", + secondaryPhone: customer.secondaryPhone ?? "", + primaryMobile: customer.primaryMobile ?? "", + secondaryMobile: customer.secondaryMobile ?? "", + + fax: customer.fax ?? "", + website: customer.website ?? "", + + legalRecord: customer.legalRecord ?? "", + + languageCode: customer.languageCode ?? "es", + currencyCode: customer.currencyCode ?? "EUR", + }; +}; diff --git a/modules/customers/src/web/create/adapters/index.ts b/modules/customers/src/web/create/adapters/index.ts new file mode 100644 index 00000000..5d44a71b --- /dev/null +++ b/modules/customers/src/web/create/adapters/index.ts @@ -0,0 +1 @@ +export * from "./customer-to-customer-create-form.adapter"; diff --git a/modules/customers/src/web/create/controllers/index.ts b/modules/customers/src/web/create/controllers/index.ts index fd35e3f4..14dd4901 100644 --- a/modules/customers/src/web/create/controllers/index.ts +++ b/modules/customers/src/web/create/controllers/index.ts @@ -1 +1,2 @@ +export * from "./use-customer-create.controller"; export * from "./use-customer-create-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 index 32ed5660..70f1da38 100644 --- 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 @@ -1,120 +1,15 @@ -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 { useNavigate } from "react-router-dom"; -import { useTranslation } from "../../i18n"; -import type { Customer } from "../../shared"; -import { type CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../types"; +import { useCustomerCreateController } from "./use-customer-create.controller"; -export interface UseCustomerCreateControllerOptions { - onCreated?(created: Customer): void; - successToasts?: boolean; // mostrar o no toast automáticcamente +export const useCustomerCreatePageController = () => { + const navigate = useNavigate(); - 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, + const createCtrl = useCustomerCreateController({ + onCreated: (customer) => navigate(`/customers/${customer.id}`), }); - /** 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, + createCtrl, }; }; diff --git a/modules/customers/src/web/create/controllers/use-customer-create.controller.ts b/modules/customers/src/web/create/controllers/use-customer-create.controller.ts new file mode 100644 index 00000000..fcae174d --- /dev/null +++ b/modules/customers/src/web/create/controllers/use-customer-create.controller.ts @@ -0,0 +1,114 @@ +import { useHookForm } from "@erp/core/hooks"; +import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; +import { useId } from "react"; +import type { FieldErrors } from "react-hook-form"; + +import { useTranslation } from "../../i18n"; +import type { 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"; + +export interface UseCustomerCreateControllerOptions { + onCreated?(created: Customer): void; + successToasts?: boolean; // mostrar o no toast automáticcamente + + onError?(error: Error, payloadData: CustomerCreatePayload): 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, + } = useCustomerCreateMutation(); + + // 2) Form hook + const form = useHookForm({ + resolverSchema: CustomerCreateFormSchema, + initialValues: defaultCustomerCreateForm, + disabled: isCreating, + }); + + /** Handlers */ + + const resetForm = () => { + form.reset(defaultCustomerCreateForm, { keepDirty: false }); + }; + + const submitHandler = form.handleSubmit( + async (formData) => { + const payloadData: CustomerCreatePayload = buildCustomerCreatePayload(formData); + + try { + // Enviamos cambios al servidor + const created = await mutateAsync(payloadData); + + if (options?.successToasts !== false) { + showSuccessToast( + t("pages.create.success.title", "Cliente creado"), + t("pages.create.success.message", "Se ha creado el cliente correctamente.") + ); + } + options?.onCreated?.(created); + } catch (error: unknown) { + const normalizedError = + error instanceof Error ? error : new Error(t("pages.create.error.unknown")); + + if (options?.errorToasts !== false) { + showErrorToast(t("pages.create.error.title"), normalizedError.message); + } + + options?.onError?.(normalizedError, payloadData); + } + }, + (errors: FieldErrors) => { + const firstKey = Object.keys(errors)[0] as keyof CustomerCreateForm | 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 + isCreating, + isCreateError, + createError, + + // No devolver FormProvider, así el controller es más + // flexible y reusable (p.ej. para un modal) + // FormProvider, + }; +}; diff --git a/modules/customers/src/web/create/entities/customer-create-form-default.ts b/modules/customers/src/web/create/entities/customer-create-form-default.ts new file mode 100644 index 00000000..76cb3d13 --- /dev/null +++ b/modules/customers/src/web/create/entities/customer-create-form-default.ts @@ -0,0 +1,43 @@ +import type { CustomerCreateForm } from "./customer-create-form.entity"; + +/** + * Valor por defecto para el formulario de creación de cliente. + * + * Reglas: + * - debe ser un objeto que cumpla con la interfaz CustomerCreateForm + * - debe tener valores por defecto razonables para cada campo, evitando campos vacíos o nulos cuando sea posible + * - el shape del objeto debe coincidir exactamente con el de CustomerCreateForm, sin campos adicionales ni transformaciones + * - orientado a la UI, no a la API ni al dominio, es decir, debe ser un objeto que se pueda usar directamente para inicializar un formulario en la interfaz de usuario + */ + +export const defaultCustomerCreateForm: CustomerCreateForm = { + reference: "", + isCompany: true, + name: "", + tradeName: "", + tin: "", + + defaultTaxes: [], + + street: "", + street2: "", + city: "", + province: "", + postalCode: "", + country: "es", + + primaryEmail: "", + secondaryEmail: "", + primaryPhone: "", + secondaryPhone: "", + primaryMobile: "", + secondaryMobile: "", + + fax: "", + website: "", + + legalRecord: "", + + languageCode: "es", + currencyCode: "EUR", +}; diff --git a/modules/customers/src/web/create/entities/customer-create-form.entity.ts b/modules/customers/src/web/create/entities/customer-create-form.entity.ts new file mode 100644 index 00000000..4e2cd2f4 --- /dev/null +++ b/modules/customers/src/web/create/entities/customer-create-form.entity.ts @@ -0,0 +1,45 @@ +/** + * CustomerCreateForm representa el shape de datos del formulario de creació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 + * con el shape del dominio ni con el de la API. + * + * Debe cumplir las siguientes reglas: + * - nombres en camelCase + * - tipos orientados a UI/form + * - sin campos de solo lectura que no se editen + * - sin shape DTO + * - sin detalles impuestos por el widget + */ + +export interface CustomerCreateForm { + reference: string; + isCompany: boolean; + name: string; + tradeName: string; + tin: string; + defaultTaxes: string[]; + + street: string; + street2: string; + city: string; + province: string; + postalCode: string; + country: string; + + primaryEmail: string; + secondaryEmail: string; + primaryPhone: string; + secondaryPhone: string; + primaryMobile: string; + secondaryMobile: string; + + fax: string; + website: string; + + legalRecord: string; + + languageCode: string; + currencyCode: string; +} diff --git a/modules/customers/src/web/create/entities/customer-create-form.schema.ts b/modules/customers/src/web/create/entities/customer-create-form.schema.ts new file mode 100644 index 00000000..63fc7991 --- /dev/null +++ b/modules/customers/src/web/create/entities/customer-create-form.schema.ts @@ -0,0 +1,48 @@ +import { z } from "zod/v4"; + +/** + * Este esquema es para validar los datos del formulario de creación de cliente. + * No tiene por qué coincidir con el shape de la entidad ni con el de la API. + * Solo define los campos que se muestran en el formulario y sus validaciones. + * + * Reglas: + * - no meter transformaciones silenciosas raras en el esquema (ej: .toUpperCase()) + * - nombres en camelCase + * - tipos orientados a UI/form + * - sin campos de solo lectura que no se editen + * - sin shape DTO + * - sin detalles impuestos por el widget + */ + +export const CustomerCreateFormSchema = z.object({ + reference: z.string().optional().or(z.literal("")), + isCompany: z.boolean(), + name: z.string().min(1, "El nombre es obligatorio"), + tradeName: z.string().optional().or(z.literal("")), + 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("")), + + primaryEmail: z.email("Email inválido").optional().or(z.literal("")), + secondaryEmail: z.email("Email inválido").optional().or(z.literal("")), + + 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("")), + + fax: z.string().optional().or(z.literal("")), + website: z.url("URL inválida").optional().or(z.literal("")), + + legalRecord: z.string().optional().or(z.literal("")), + + languageCode: z.string().min(1, "El idioma es obligatorio"), + currencyCode: z.string().min(1, "La moneda es obligatoria"), +}); diff --git a/modules/customers/src/web/create/entities/customer-create-payload.entity.ts b/modules/customers/src/web/create/entities/customer-create-payload.entity.ts new file mode 100644 index 00000000..a731fbb8 --- /dev/null +++ b/modules/customers/src/web/create/entities/customer-create-payload.entity.ts @@ -0,0 +1,23 @@ +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 & { + // 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; +}; diff --git a/modules/customers/src/web/create/entities/index.ts b/modules/customers/src/web/create/entities/index.ts new file mode 100644 index 00000000..e208b9ee --- /dev/null +++ b/modules/customers/src/web/create/entities/index.ts @@ -0,0 +1,4 @@ +export * from "./customer-create-form.entity"; +export * from "./customer-create-form.schema"; +export * from "./customer-create-form-default"; +export * from "./customer-create-payload.entity"; diff --git a/modules/customers/src/web/create/types/index.ts b/modules/customers/src/web/create/types/index.ts deleted file mode 100644 index eea524d6..00000000 --- a/modules/customers/src/web/create/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./types"; diff --git a/modules/customers/src/web/create/types/types.ts b/modules/customers/src/web/create/types/types.ts deleted file mode 100644 index 97eb0ddb..00000000 --- a/modules/customers/src/web/create/types/types.ts +++ /dev/null @@ -1,89 +0,0 @@ -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/pages/customer-create-page.tsx b/modules/customers/src/web/create/ui/pages/customer-create-page.tsx index c141fed5..d3948a3f 100644 --- a/modules/customers/src/web/create/ui/pages/customer-create-page.tsx +++ b/modules/customers/src/web/create/ui/pages/customer-create-page.tsx @@ -1,110 +1,49 @@ -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 { ErrorAlert, PageHeader } from "@erp/core/components"; +import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks"; +import { AppContent, AppHeader } from "@repo/rdx-ui/components"; +import { FormProvider } from "react-hook-form"; import { useTranslation } from "../../../i18n"; -import { useCustomerCreateController } from "../../controllers"; -import { CustomerEditorSkeleton } from "../components"; -import { CustomerEditForm } from "../editor"; +import { useCustomerCreatePageController } from "../../controllers"; + +import { CustomerCreateForm } from "./editor"; export const CustomerCreatePage = () => { - const initialCustomerId = useUrlParamId(); const { t } = useTranslation(); + const { createCtrl } = useCustomerCreatePageController(); - const { - form, - formId, - onSubmit, - resetForm, - - customerData, - isLoading, - isLoadError, - loadError, - - isUpdating, - isCreateError, - createError, - - FormProvider, - } = useCustomerCreateController(initialCustomerId, {}); - - if (isLoading) { - return ; - } - - if (isLoadError) { - return ( - <> - - - -
- -
-
- - ); - } - - if (!customerData) - return ( - <> - - - - - ); + const { form, formId, onSubmit, resetForm, isCreating, isCreateError, createError } = createCtrl; 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/editor/customer-additional-config-fields.tsx b/modules/customers/src/web/create/ui/pages/editor/customer-additional-config-fields.tsx new file mode 100644 index 00000000..daaab3f1 --- /dev/null +++ b/modules/customers/src/web/create/ui/pages/editor/customer-additional-config-fields.tsx @@ -0,0 +1,53 @@ +import { SelectField } from "@repo/rdx-ui/components"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLegend, + FieldSet, +} from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../../i18n"; +import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../../shared"; + +interface CustomerAdditionalConfigFieldsProps { + className?: string; +} + +export const CustomerAdditionalConfigFields = ({ + className, + ...props +}: CustomerAdditionalConfigFieldsProps) => { + const { t } = useTranslation(); + + return ( +
+ {t("form_groups.preferences.title")} + {t("form_groups.preferences.description")} + + + + + + + + + +
+ ); +}; diff --git a/modules/customers/src/web/create/ui/pages/editor/customer-address-fields.tsx b/modules/customers/src/web/create/ui/pages/editor/customer-address-fields.tsx new file mode 100644 index 00000000..6f27d192 --- /dev/null +++ b/modules/customers/src/web/create/ui/pages/editor/customer-address-fields.tsx @@ -0,0 +1,76 @@ +import { SelectField, TextField } from "@repo/rdx-ui/components"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLegend, + FieldSet, +} from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../../i18n"; +import { COUNTRY_OPTIONS } from "../../../shared"; + +interface CustomerAddressFieldsProps { + className?: string; +} + +export const CustomerAddressFields = ({ className, ...props }: CustomerAddressFieldsProps) => { + const { t } = useTranslation(); + + return ( +
+ {t("form_groups.address.title")} + {t("form_groups.address.description")} + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/modules/customers/src/web/create/ui/pages/editor/customer-basic-info-fields.tsx b/modules/customers/src/web/create/ui/pages/editor/customer-basic-info-fields.tsx new file mode 100644 index 00000000..99d25165 --- /dev/null +++ b/modules/customers/src/web/create/ui/pages/editor/customer-basic-info-fields.tsx @@ -0,0 +1,171 @@ +import { FormFieldLabel, TextAreaField, TextField } from "@repo/rdx-ui/components"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, + RadioGroup, + RadioGroupItem, +} from "@repo/shadcn-ui/components"; +import { useEffect } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import { useTranslation } from "../../../i18n"; +import type { CustomerUpdateForm } from "../../entities"; + +import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select"; + +interface CustomerBasicInfoFieldsProps extends React.ComponentProps {} + +export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicInfoFieldsProps) => { + const { t } = useTranslation(); + const { control, setFocus } = useFormContext(); + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( +
+ {t("form_groups.basic_info.title")} + {t("form_groups.basic_info.description")} + + + + + { + console.log(field.value); + return ( + + {t("form_fields.customer_type.label")} + + { + console.log("Pongo ", value); + field.onChange(value === "true"); + }} + required + value={field.value ? "true" : "false"} + > + + + + + {t("form_fields.customer_type.company")} + + + + + + + + + {t("form_fields.customer_type.individual")} + + + + + + {t("form_fields.customer_type.description")} + + + ); + }} + /> + + + + + + + + + ( + + + {t("form_fields.default_taxes.label")} + + + + + {t("form_fields.default_taxes.description")} + + + )} + /> + + + + +
+ ); +}; diff --git a/modules/customers/src/web/create/ui/pages/editor/customer-contact-fields.tsx b/modules/customers/src/web/create/ui/pages/editor/customer-contact-fields.tsx new file mode 100644 index 00000000..a477ce23 --- /dev/null +++ b/modules/customers/src/web/create/ui/pages/editor/customer-contact-fields.tsx @@ -0,0 +1,156 @@ +import { TextField } from "@repo/rdx-ui/components"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + Field, + FieldDescription, + FieldGroup, + FieldLegend, + FieldSet, + Separator, +} from "@repo/shadcn-ui/components"; +import { AtSignIcon, ChevronDown, GlobeIcon, PhoneIcon, SmartphoneIcon } from "lucide-react"; +import { useState } from "react"; + +import { useTranslation } from "../../../i18n"; + +interface CustomerContactFieldsProps { + className?: string; +} + +export const CustomerContactFields = ({ className, ...props }: CustomerContactFieldsProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(true); + + return ( +
+ {t("form_groups.contact_info.title")} + {t("form_groups.contact_info.description")} + + + } + name="primaryEmail" + placeholder={t("form_fields.email_primary.placeholder")} + required + typePreset="email" + /> + + + } + name="primaryMobile" + placeholder={t("form_fields.mobile_primary.placeholder")} + typePreset="phone" + /> + + + } + name="primaryPhone" + placeholder={t("form_fields.phone_primary.placeholder")} + typePreset="phone" + /> + + + + + } + name="secondaryEmail" + placeholder={t("form_fields.email_secondary.placeholder")} + typePreset="email" + /> + + + } + name="secondaryMobile" + placeholder={t("form_fields.mobile_secondary.placeholder")} + typePreset="phone" + /> + + } + name="secondaryPhone" + placeholder={t("form_fields.phone_secondary.placeholder")} + typePreset="phone" + /> + + + + + + {t("common.more_details")}{" "} + + + + + + + } + name="website" + placeholder={t("form_fields.website.placeholder")} + typePreset="text" + /> + + + + + + + + +
+ ); +}; diff --git a/modules/customers/src/web/create/ui/pages/editor/customer-create-form.tsx b/modules/customers/src/web/create/ui/pages/editor/customer-create-form.tsx new file mode 100644 index 00000000..0d7072d5 --- /dev/null +++ b/modules/customers/src/web/create/ui/pages/editor/customer-create-form.tsx @@ -0,0 +1,25 @@ +import { cn } from "@repo/shadcn-ui/lib/utils"; + +import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields"; +import { CustomerAddressFields } from "./customer-address-fields"; +import { CustomerBasicInfoFields } from "./customer-basic-info-fields"; +import { CustomerContactFields } from "./customer-contact-fields"; + +type CustomerFormProps = { + formId: string; + onSubmit: (event: React.FormEvent) => void; + className?: string; +}; + +export const CustomerCreateForm = ({ formId, onSubmit, className }: CustomerFormProps) => { + return ( + +
+ + + + +
+ + ); +}; diff --git a/modules/customers/src/web/create/ui/pages/editor/customer-taxes-multi-select.tsx b/modules/customers/src/web/create/ui/pages/editor/customer-taxes-multi-select.tsx new file mode 100644 index 00000000..7c476b2f --- /dev/null +++ b/modules/customers/src/web/create/ui/pages/editor/customer-taxes-multi-select.tsx @@ -0,0 +1,65 @@ +import { SpainTaxCatalogProvider } from "@erp/core"; +import { MultiSelect } from "@repo/rdx-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { useCallback, useMemo } from "react"; + +import { useTranslation } from "../../../i18n"; + +interface CustomerTaxesMultiSelectProps { + value?: string[]; + onChange: (selectedValues: string[]) => void; + className?: string; + inputId?: string; + [key: string]: unknown; +} + +export const CustomerTaxesMultiSelect = ({ + value, + onChange, + className, + inputId, + ...otherProps +}: CustomerTaxesMultiSelectProps) => { + const { t } = useTranslation(); + + const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []); + const catalogLookup = useMemo(() => taxCatalog.toOptionLookup(), [taxCatalog]); + + const filterSelectedByGroup = useCallback( + (selectedValues: string[]) => { + const groupMap = new Map(); + + for (const code of selectedValues) { + const item = taxCatalog.findByCode(code).getOrUndefined(); + const group = item?.group ?? "ungrouped"; + groupMap.set(group, code); + } + + return Array.from(groupMap.values()); + }, + [taxCatalog] + ); + + return ( +
+ +
+ ); +}; diff --git a/modules/customers/src/web/create/ui/pages/editor/index.ts b/modules/customers/src/web/create/ui/pages/editor/index.ts new file mode 100644 index 00000000..b6302366 --- /dev/null +++ b/modules/customers/src/web/create/ui/pages/editor/index.ts @@ -0,0 +1 @@ +export * from "./customer-create-form"; diff --git a/modules/customers/src/web/create/utils/build-customer-create-payload.ts b/modules/customers/src/web/create/utils/build-customer-create-payload.ts new file mode 100644 index 00000000..3eaa4c04 --- /dev/null +++ b/modules/customers/src/web/create/utils/build-customer-create-payload.ts @@ -0,0 +1,29 @@ +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(), + }; +}; diff --git a/modules/customers/src/web/create/utils/index.ts b/modules/customers/src/web/create/utils/index.ts new file mode 100644 index 00000000..695f9369 --- /dev/null +++ b/modules/customers/src/web/create/utils/index.ts @@ -0,0 +1 @@ +export * from "./build-customer-create-payload"; diff --git a/modules/customers/src/web/customer-routes.tsx b/modules/customers/src/web/customer-routes.tsx index 7c5aa05f..804ade5c 100644 --- a/modules/customers/src/web/customer-routes.tsx +++ b/modules/customers/src/web/customer-routes.tsx @@ -9,7 +9,7 @@ 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("./pages").then((m) => ({ default: m.CustomerCreatePage }))); +const CustomerAdd = lazy(() => import("./create").then((m) => ({ default: m.CustomerCreatePage }))); const CustomerUpdate = lazy(() => import("./update").then((m) => ({ default: m.CustomerUpdatePage })) ); diff --git a/modules/customers/src/web/shared/hooks/index.ts b/modules/customers/src/web/shared/hooks/index.ts index 5760e9f3..6c7aa19c 100644 --- a/modules/customers/src/web/shared/hooks/index.ts +++ b/modules/customers/src/web/shared/hooks/index.ts @@ -1,3 +1,5 @@ +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"; 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 index f41fad16..4904ea30 100644 --- a/modules/customers/src/web/shared/hooks/use-customer-create-mutation.ts +++ b/modules/customers/src/web/shared/hooks/use-customer-create-mutation.ts @@ -16,28 +16,23 @@ 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({ + return useMutation({ mutationKey: CUSTOMER_CREATE_KEY, mutationFn: async (payload) => { - const { data } = payload; const id = UniqueID.generateNewID().toString(); - const result = schema.safeParse(data); + const result = schema.safeParse(payload); if (!result.success) { throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); } - const dto = await createCustomer(dataSource, id, data); + const dto = await createCustomer(dataSource, id, payload); return GetCustomerByIdAdapter.fromDTO(dto); }, 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 486e525c..00000000 --- a/modules/customers/src/web/update/hooks/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./use-customer-update-form"; -export * from "./use-customer-update-patch"; diff --git a/modules/customers/src/web/update/hooks/use-customer-update-form.ts b/modules/customers/src/web/update/hooks/use-customer-update-form.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/customers/src/web/update/hooks/use-customer-update-patch.ts b/modules/customers/src/web/update/hooks/use-customer-update-patch.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/customers/src/web/update/utils/build-customer-update-patch.ts b/modules/customers/src/web/update/utils/build-customer-update-patch.ts index 7f668615..f64a12f7 100644 --- a/modules/customers/src/web/update/utils/build-customer-update-patch.ts +++ b/modules/customers/src/web/update/utils/build-customer-update-patch.ts @@ -17,7 +17,8 @@ import type { CustomerUpdateForm, CustomerUpdatePatch } from "../entities"; * * @param formData * @param dirtyFields - * @returns + * @returns Un objeto que se puede enviar a la API para actualizar un cliente, + * con solo los campos que han cambiado. */ export const buildCustomerUpdatePatch = (