diff --git a/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx b/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx index 209d0415..8fb70c6b 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/pages/proforma-create-page.tsx @@ -1,6 +1,6 @@ import { ErrorAlert } from "@erp/core/components"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; -import { SelectCustomerDialog } from "@erp/customers"; +import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers"; import { FormProvider } from "react-hook-form"; import { ProformaInfoAlert } from "../../../update/ui/components"; @@ -66,7 +66,11 @@ export const ProformaCreatePage = () => { /> - + + diff --git a/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-selected-customer.adapter.ts b/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-selected-customer.adapter.ts index 71e5415e..1ec69488 100644 --- a/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-selected-customer.adapter.ts +++ b/modules/customer-invoices/src/web/proformas/update/adapters/map-proforma-to-selected-customer.adapter.ts @@ -1,4 +1,4 @@ -import type { CustomerSelectionOption } from "@erp/customers/common"; +import type { CustomerSelectionOption } from "@erp/customers"; import type { Proforma } from "../../shared/entities"; @@ -21,19 +21,19 @@ export const mapProformaToSelectedCustomer = ( id: proforma.customerId, name: proforma.recipient.name ?? "", - tradeName: "", - tin: proforma.recipient.tin ?? "", + tradeName: null, + tin: proforma.recipient.tin, - street: proforma.recipient.street ?? "", - street2: proforma.recipient.street2 ?? "", - city: proforma.recipient.city ?? "", - province: proforma.recipient.province ?? "", - postalCode: proforma.recipient.postalCode ?? "", - country: proforma.recipient.country ?? "", + street: proforma.recipient.street, + street2: proforma.recipient.street2, + city: proforma.recipient.city, + province: proforma.recipient.province, + postalCode: proforma.recipient.postalCode, + country: proforma.recipient.country, - primaryEmail: "", - primaryMobile: "", - primaryPhone: "", + primaryEmail: null, + primaryMobile: null, + primaryPhone: null, isCompany: true, }; diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts index df9f25c3..da621f12 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts @@ -56,6 +56,21 @@ const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection return new Error("Unknown error"); }; +const resolveSelectedCustomerState = ( + proforma: Proforma | null | undefined, + currentSelectedCustomer: CustomerSelectionOption | null +): CustomerSelectionOption | null => { + if (!proforma?.customerId) { + return null; + } + + if (currentSelectedCustomer?.id === proforma.customerId) { + return currentSelectedCustomer; + } + + return mapProformaToSelectedCustomer(proforma); +}; + export const useUpdateProformaController = ( proformaId?: string, options?: UseUpdateProformaControllerOptions @@ -107,7 +122,9 @@ export const useUpdateProformaController = ( keepDirty: false, // <-- importante: no marca el form como "dirty" al cargar los datos reales }); - setSelectedCustomer(mapProformaToSelectedCustomer(proformaData)); + setSelectedCustomer((currentSelectedCustomer) => + resolveSelectedCustomerState(proformaData, currentSelectedCustomer) + ); }, [proformaData, form]); /** Handlers */ @@ -118,7 +135,9 @@ export const useUpdateProformaController = ( : buildProformaUpdateDefault(); form.reset(initialData, { keepDirty: false }); - setSelectedCustomer(mapProformaToSelectedCustomer(proformaData)); + setSelectedCustomer((currentSelectedCustomer) => + resolveSelectedCustomerState(proformaData, currentSelectedCustomer) + ); }; const setCustomer = (customer: CustomerSelectionOption) => { @@ -180,7 +199,9 @@ export const useUpdateProformaController = ( keepDirty: false, }); - setSelectedCustomer(mapProformaToSelectedCustomer(updated)); + setSelectedCustomer((currentSelectedCustomer) => + resolveSelectedCustomerState(updated, currentSelectedCustomer) + ); if (options?.successToasts !== false) { showSuccessToast( diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx new file mode 100644 index 00000000..43c20c7d --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.test.tsx @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; + +test("conecta la alta rápida directa y la alta rápida desde selector con callbacks distintos", async () => { + const openSelectDialog = () => undefined; + const openQuickCreateDialog = () => undefined; + const openQuickCreateFromSelection = () => undefined; + const quickCreateCtrl = { + isOpen: false, + }; + + let capturedEditorProps: Record | undefined; + let capturedSelectDialogProps: Record | undefined; + let capturedQuickCreateDialogProps: Record | undefined; + + mock.module("@erp/core/components", { + namedExports: { + ErrorAlert: () => null, + PageErrorState: () => null, + }, + }); + + mock.module("@erp/core/hooks", { + namedExports: { + UnsavedChangesProvider: ({ children }: { children: ReactNode }) => children, + useReturnToNavigation: () => ({ + navigateBack: () => undefined, + }), + }, + }); + + mock.module("@erp/customers", { + namedExports: { + QuickCreateCustomerDialog: (props: Record) => { + capturedQuickCreateDialogProps = props; + return null; + }, + SelectCustomerDialog: (props: Record) => { + capturedSelectDialogProps = props; + return null; + }, + }, + }); + + mock.module("@repo/rdx-ui/components", { + namedExports: { + AppContent: ({ children }: { children: ReactNode }) => children, + CancelActionButton: () => null, + PercentageField: () => null, + RdxEditorStickyFooter: () => null, + RhfSubmitActionButton: () => null, + }, + }); + + mock.module("react-hook-form", { + namedExports: { + FormProvider: ({ children }: { children: ReactNode }) => children, + }, + }); + + mock.module("../../../../i18n", { + namedExports: { + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), + }, + }); + + mock.module("../../controllers/use-update-proforma-page-controller", { + namedExports: { + useUpdateProformaPageController: () => ({ + updateCtrl: { + form: { + formState: { + isDirty: false, + isSubmitting: false, + }, + }, + formId: "proforma-update-form", + isUpdating: false, + isUpdateError: false, + isSeriesLoadError: false, + itemsCtrl: {}, + onSubmit: () => undefined, + paymentCtrl: {}, + resetForm: () => undefined, + selectedCustomer: { + id: "customer-1", + status: "active", + isCompany: true, + name: "Acme", + tradeName: null, + tin: null, + street: null, + street2: null, + city: null, + province: null, + postalCode: null, + country: null, + primaryEmail: null, + primaryPhone: null, + primaryMobile: null, + }, + seriesOptions: [], + taxCtrl: { + hasRecPercentage: false, + hasRetentionPercentage: false, + }, + totalsCtrl: { + totals: {}, + }, + currencyCode: "EUR", + }, + selectCustomerCtrl: { + selectCtrl: { + openDialog: openSelectDialog, + }, + createCtrl: quickCreateCtrl, + openQuickCreateDialog, + openQuickCreateFromSelection, + }, + returnTo: "/proformas", + pageError: null, + retryUpdate: async () => undefined, + goHome: () => undefined, + goBackToList: () => undefined, + isRetrying: false, + isInitialLoading: false, + }), + }, + }); + + mock.module("../blocks", { + namedExports: { + ProformaCompactTotals: () => null, + ProformaUpdateHeader: ({ children }: { children: ReactNode }) => children, + }, + }); + + mock.module("../components", { + namedExports: { + ProformaInfoAlert: ({ children }: { children: ReactNode }) => children, + ProformaUpdateSkeleton: () => null, + }, + }); + + mock.module("../editors", { + namedExports: { + ProformaUpdateEditorForm: (props: Record) => { + capturedEditorProps = props; + return null; + }, + }, + }); + + const { ProformaUpdatePage } = await import("./proforma-update-page.tsx"); + + renderToStaticMarkup(); + + assert.equal(capturedEditorProps?.onChangeCustomerClick, openSelectDialog); + assert.equal(capturedEditorProps?.onCreateCustomerClick, openQuickCreateDialog); + assert.equal( + capturedSelectDialogProps?.onCreateNewCustomerClick, + openQuickCreateFromSelection + ); + assert.equal(capturedQuickCreateDialogProps?.ctrl, quickCreateCtrl); + + mock.restoreAll(); +}); diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx index b4d14700..307fc9ca 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx @@ -1,6 +1,6 @@ import { ErrorAlert, PageErrorState } from "@erp/core/components"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; -import { SelectCustomerDialog } from "@erp/customers"; +import { QuickCreateCustomerDialog, SelectCustomerDialog } from "@erp/customers"; import { AppContent, CancelActionButton, @@ -141,7 +141,7 @@ export const ProformaUpdatePage = () => { isSubmitting={updateCtrl.isUpdating} itemsCtrl={updateCtrl.itemsCtrl} onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog} - onCreateCustomerClick={() => null} + onCreateCustomerClick={selectCustomerCtrl.openQuickCreateDialog} onReset={updateCtrl.resetForm} onSubmit={updateCtrl.onSubmit} paymentCtrl={updateCtrl.paymentCtrl} @@ -194,7 +194,11 @@ export const ProformaUpdatePage = () => { /> - + + diff --git a/modules/customers/src/common/features/customer-selection/controllers/use-customer-selection-flow.test.tsx b/modules/customers/src/common/features/customer-selection/controllers/use-customer-selection-flow.test.tsx new file mode 100644 index 00000000..8f525cf4 --- /dev/null +++ b/modules/customers/src/common/features/customer-selection/controllers/use-customer-selection-flow.test.tsx @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +test("expone apertura directa y apertura desde selector con comportamientos distintos", async () => { + let selectDialogOpenCalls = 0; + let selectDialogCloseCalls = 0; + let quickCreateOpenCalls = 0; + let quickCreateOptions: + | { + onCustomerCreated(customer: { id: string }): void; + onCancel?(): void; + } + | undefined; + + mock.module("../../quick-create-customer", { + namedExports: { + QuickCreateCustomerDialog: () => null, + useQuickCreateCustomerFlow: ( + options: NonNullable + ) => { + quickCreateOptions = options; + + return { + createCtrl: { + openDialog: () => { + quickCreateOpenCalls += 1; + }, + }, + }; + }, + }, + }); + + mock.module("./use-select-customer-dialog-controller", { + namedExports: { + useSelectCustomerDialogController: () => ({ + openDialog: () => { + selectDialogOpenCalls += 1; + }, + closeDialog: () => { + selectDialogCloseCalls += 1; + }, + }), + }, + }); + + mock.module("../ui", { + namedExports: { + SelectCustomerDialog: () => null, + }, + }); + + const { useCustomerSelectionFlow } = await import("./use-customer-selection-flow.ts"); + + const selectedCustomers: Array<{ id: string }> = []; + let flow: + | ReturnType + | undefined; + + const Harness = () => { + flow = useCustomerSelectionFlow({ + onCustomerSelected: (customer) => { + selectedCustomers.push(customer as { id: string }); + }, + }); + + return null; + }; + + renderToStaticMarkup(); + + assert.ok(flow); + + flow.openQuickCreateDialog(); + assert.equal(quickCreateOpenCalls, 1); + assert.equal(selectDialogCloseCalls, 0); + + flow.openQuickCreateFromSelection(); + assert.equal(selectDialogCloseCalls, 1); + assert.equal(quickCreateOpenCalls, 2); + + assert.ok(quickCreateOptions); + + quickCreateOptions.onCustomerCreated({ id: "customer-1" }); + assert.deepEqual(selectedCustomers, [{ id: "customer-1" }]); + + quickCreateOptions.onCancel?.(); + assert.equal(selectDialogOpenCalls, 1); + + mock.restoreAll(); +}); diff --git a/modules/customers/src/common/features/customer-selection/controllers/use-customer-selection-flow.ts b/modules/customers/src/common/features/customer-selection/controllers/use-customer-selection-flow.ts index 6e457870..ad2acc1c 100644 --- a/modules/customers/src/common/features/customer-selection/controllers/use-customer-selection-flow.ts +++ b/modules/customers/src/common/features/customer-selection/controllers/use-customer-selection-flow.ts @@ -1,4 +1,5 @@ import type { CustomerSelectionOption } from "../entities"; +import { QuickCreateCustomerDialog, useQuickCreateCustomerFlow } from "../../quick-create-customer"; import { SelectCustomerDialog } from "../ui"; import { useSelectCustomerDialogController } from "./use-select-customer-dialog-controller"; @@ -12,19 +13,33 @@ export const useCustomerSelectionFlow = (options: { onSelect: options.onCustomerSelected, }); - /*const createCtrl = useQuickCreateCustomerDialogController({ - onCreated: (customer) => { + const quickCreateFlow = useQuickCreateCustomerFlow({ + onCustomerCreated: (customer) => { options.onCustomerSelected(customer); - selectCtrl.closeDialog(); + }, + onCancel: () => { + selectCtrl.openDialog(); }, defaultLanguageCode: options.defaultLanguageCode, defaultCurrencyCode: options.defaultCurrencyCode, - });*/ + }); + + const openQuickCreateFromSelection = () => { + selectCtrl.closeDialog(); + quickCreateFlow.createCtrl.openDialog(); + }; + + const openQuickCreateDialog = () => { + quickCreateFlow.createCtrl.openDialog(); + }; return { selectCtrl, - //createCtrl, + createCtrl: quickCreateFlow.createCtrl, + openQuickCreateDialog, + openQuickCreateFromSelection, SelectCustomerDialog, + QuickCreateCustomerDialog, }; }; diff --git a/modules/customers/src/common/features/customer-selection/ui/dialogs/select-customer-dialog.tsx b/modules/customers/src/common/features/customer-selection/ui/dialogs/select-customer-dialog.tsx index 4ba7ceda..20c8ea4a 100644 --- a/modules/customers/src/common/features/customer-selection/ui/dialogs/select-customer-dialog.tsx +++ b/modules/customers/src/common/features/customer-selection/ui/dialogs/select-customer-dialog.tsx @@ -7,7 +7,7 @@ import { DialogHeader, DialogTitle, } from "@repo/shadcn-ui/components"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { CustomerSelectionOption } from "../../../../../common/features"; import { useTranslation } from "../../../../../web/i18n"; @@ -41,6 +41,12 @@ export const SelectCustomerDialog = ({ } }; + useEffect(() => { + if (!ctrl.isOpen) { + setSelectedCustomer(undefined); + } + }, [ctrl.isOpen]); + return ( !open && ctrl.closeDialog()} open={ctrl.isOpen}> @@ -59,14 +65,6 @@ export const SelectCustomerDialog = ({ )} value={ctrl.search} /> - -
- {onCreateNewCustomerClick ? ( - - ) : null} -
@@ -78,13 +76,22 @@ export const SelectCustomerDialog = ({ />
- - - + +
+ {onCreateNewCustomerClick ? ( + + ) : null} +
+
+ + +
diff --git a/modules/customers/src/common/features/quick-create-customer/README.md b/modules/customers/src/common/features/quick-create-customer/README.md new file mode 100644 index 00000000..3131d093 --- /dev/null +++ b/modules/customers/src/common/features/quick-create-customer/README.md @@ -0,0 +1,31 @@ +# Quick Create Customer + +Feature pública reutilizable del módulo `customers` para alta rápida embebida. + +## API pública + +```ts +import { + QuickCreateCustomerDialog, + useQuickCreateCustomerFlow, +} from "@erp/customers/common"; +``` + +## Ejemplo mínimo + +```tsx +const quickCreate = useQuickCreateCustomerFlow({ + onCustomerCreated: (customer) => { + formCtrl.setCustomer(customer); + }, +}); + + +; +``` + +## Contrato + +- Éxito: devuelve un `CustomerSelectionOption`, listo para reutilizarse en flujos de selección. +- Cancelación: no crea nada, cierra el diálogo y dispara `onCancel` si el consumidor lo ha proporcionado. +- Integración con `customer-selection`: puede abrirse desde el selector y devolver el cliente para dejarlo seleccionado automáticamente. diff --git a/modules/customers/src/common/features/quick-create-customer/controllers/index.ts b/modules/customers/src/common/features/quick-create-customer/controllers/index.ts new file mode 100644 index 00000000..7f96b48a --- /dev/null +++ b/modules/customers/src/common/features/quick-create-customer/controllers/index.ts @@ -0,0 +1,2 @@ +export * from "./use-quick-create-customer-dialog-controller"; +export * from "./use-quick-create-customer-flow"; diff --git a/modules/customers/src/common/features/quick-create-customer/controllers/use-quick-create-customer-dialog-controller.ts b/modules/customers/src/common/features/quick-create-customer/controllers/use-quick-create-customer-dialog-controller.ts new file mode 100644 index 00000000..77946641 --- /dev/null +++ b/modules/customers/src/common/features/quick-create-customer/controllers/use-quick-create-customer-dialog-controller.ts @@ -0,0 +1,170 @@ +import { applyValidationErrorCollection } from "@erp/core"; +import { useHookForm } from "@erp/core/hooks"; +import { + UniqueID, + type ValidationErrorCollection, + isValidationErrorCollection, +} from "@repo/rdx-ddd"; +import { + focusFirstInputFormError, + showErrorToast, + showSuccessToast, + showWarningToast, +} from "@repo/rdx-ui/helpers"; +import { useId, useState } from "react"; +import type { FieldErrors } from "react-hook-form"; + +import type { CustomerSelectionOption } from "../../customer-selection"; +import { buildCustomerSelectionOption } from "../../customer-selection"; +import { useTranslation } from "../../../../web/i18n"; +import { useCustomerCreateMutation } from "../../../../web/shared/hooks"; +import { type QuickCustomerCreateForm, QuickCustomerCreateFormSchema } from "../entities"; +import { + buildCreateCustomerParamsFromQuickForm, + buildQuickCustomerCreateDefaultValues, +} from "../utils"; + +type UseQuickCreateCustomerDialogControllerOptions = { + onCreated(customer: CustomerSelectionOption): void; + onCancel?(): void; + defaultLanguageCode?: string; + defaultCurrencyCode?: string; + defaultCountry?: string; +}; + +const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => { + if (isValidationErrorCollection(error)) { + return error; + } + + if (error instanceof Error) { + return error; + } + + return new Error("Unknown error"); +}; + +/** + * Controla el flujo embebible de alta rápida de cliente. + * Mantiene el contrato público desacoplado de páginas o rutas internas. + */ +export const useQuickCreateCustomerDialogController = ( + options: UseQuickCreateCustomerDialogControllerOptions +) => { + const { t } = useTranslation(); + const formId = useId(); + const [isOpen, setIsOpen] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const { + mutateAsync, + isPending: isSubmitting, + isError: isCreateError, + } = useCustomerCreateMutation(); + + const form = useHookForm({ + resolverSchema: QuickCustomerCreateFormSchema, + initialValues: buildQuickCustomerCreateDefaultValues(), + disabled: isSubmitting, + }); + + const resetForm = () => { + setSubmitError(null); + form.reset(buildQuickCustomerCreateDefaultValues(), { keepDirty: false }); + }; + + const openDialog = () => { + resetForm(); + setIsOpen(true); + }; + + const closeDialog = () => { + setIsOpen(false); + }; + + const cancelDialog = () => { + closeDialog(); + resetForm(); + options.onCancel?.(); + }; + + const submitHandler = form.handleSubmit( + async (formData) => { + setSubmitError(null); + + const params = buildCreateCustomerParamsFromQuickForm( + UniqueID.generateNewID().toString(), + formData, + { + country: options.defaultCountry, + languageCode: options.defaultLanguageCode, + currencyCode: options.defaultCurrencyCode, + } + ); + + try { + const created = await mutateAsync(params); + const customer = buildCustomerSelectionOption(created); + + options.onCreated(customer); + + showSuccessToast( + t("toasts.quick_create_customer.success.title"), + t("toasts.quick_create_customer.success.message") + ); + + closeDialog(); + resetForm(); + } catch (error: unknown) { + const normalizedError = normalizeSubmitError(error); + + if (isValidationErrorCollection(normalizedError)) { + applyValidationErrorCollection(form, normalizedError); + focusFirstInputFormError(form); + + showWarningToast( + t("create.feedback.validation.title"), + t("create.feedback.validation.message") + ); + return; + } + + setSubmitError(normalizedError); + + showErrorToast( + t("toasts.quick_create_customer.error.title"), + normalizedError.message || t("errors.unexpected") + ); + } + }, + (errors: FieldErrors) => { + console.error(errors); + setSubmitError(null); + focusFirstInputFormError(form); + + showWarningToast( + t("common.feedback.validation.title"), + t("common.feedback.validation.message") + ); + } + ); + + const onSubmit = (event: React.SubmitEvent) => { + event.stopPropagation(); + submitHandler(event); + }; + + return { + formId, + form, + isOpen, + isSubmitting, + isCreateError, + submitError, + openDialog, + closeDialog, + cancelDialog, + resetForm, + onSubmit, + }; +}; diff --git a/modules/customers/src/common/features/quick-create-customer/controllers/use-quick-create-customer-flow.ts b/modules/customers/src/common/features/quick-create-customer/controllers/use-quick-create-customer-flow.ts new file mode 100644 index 00000000..9a7277c2 --- /dev/null +++ b/modules/customers/src/common/features/quick-create-customer/controllers/use-quick-create-customer-flow.ts @@ -0,0 +1,31 @@ +import type { CustomerSelectionOption } from "../../customer-selection"; +import { QuickCreateCustomerDialog } from "../ui"; + +import { useQuickCreateCustomerDialogController } from "./use-quick-create-customer-dialog-controller"; + +type UseQuickCreateCustomerFlowOptions = { + onCustomerCreated(customer: CustomerSelectionOption): void; + onCancel?(): void; + defaultLanguageCode?: string; + defaultCurrencyCode?: string; + defaultCountry?: string; +}; + +/** + * Expone el mismo modelo mental que otras features públicas: + * hook de flujo + componente de diálogo reutilizable. + */ +export const useQuickCreateCustomerFlow = (options: UseQuickCreateCustomerFlowOptions) => { + const createCtrl = useQuickCreateCustomerDialogController({ + onCreated: options.onCustomerCreated, + onCancel: options.onCancel, + defaultLanguageCode: options.defaultLanguageCode, + defaultCurrencyCode: options.defaultCurrencyCode, + defaultCountry: options.defaultCountry, + }); + + return { + createCtrl, + QuickCreateCustomerDialog, + }; +}; diff --git a/modules/customers/src/common/features/quick-create-customer/entities/index.ts b/modules/customers/src/common/features/quick-create-customer/entities/index.ts index bb6f46a8..3f2f6e0a 100644 --- a/modules/customers/src/common/features/quick-create-customer/entities/index.ts +++ b/modules/customers/src/common/features/quick-create-customer/entities/index.ts @@ -1,2 +1,3 @@ export * from './quick-customer-create-form.entity'; +export * from "./quick-customer-create-form.schema"; export * from './quick-customer-create-result.entity'; diff --git a/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.entity.ts b/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.entity.ts index 4e7fb786..80a39adf 100644 --- a/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.entity.ts +++ b/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.entity.ts @@ -1,15 +1 @@ -/** - * Formulario mínimo para alta rápida desde otros módulos. - * SOLO campos necesarios para poder usar el cliente inmediatamente. - */ -export interface QuickCustomerCreateForm { - isCompany: boolean; - name: string; - tradeName: string; - tin: string; - - EmailPrimary: string; - - languageCode: string; - currencyCode: string; -} +export type { QuickCustomerCreateForm } from "./quick-customer-create-form.schema"; diff --git a/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.schema.test.ts b/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.schema.test.ts new file mode 100644 index 00000000..755492ff --- /dev/null +++ b/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.schema.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { QuickCustomerCreateFormSchema } from "./quick-customer-create-form.schema"; + +test("valida los campos mínimos del alta rápida", () => { + const result = QuickCustomerCreateFormSchema.safeParse({ + isCompany: true, + name: "Acme Corp", + tin: "B12345678", + primaryEmail: "billing@acme.test", + }); + + assert.equal(result.success, true); +}); + +test("falla cuando falta un dato técnicamente obligatorio", () => { + const result = QuickCustomerCreateFormSchema.safeParse({ + isCompany: true, + name: "", + tin: "", + primaryEmail: "", + }); + + assert.equal(result.success, false); +}); diff --git a/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.schema.ts b/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.schema.ts new file mode 100644 index 00000000..2da1fffe --- /dev/null +++ b/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-form.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod/v4"; + +import { CustomerCreateFormSchema } from "../../../../web/create/entities"; + +/** + * SSOT del formulario de alta rápida. + * Reutiliza las validaciones del formulario oficial y limita el shape + * a los campos mínimos de esta operación pública. + */ +export const QuickCustomerCreateFormSchema = CustomerCreateFormSchema.pick({ + isCompany: true, + name: true, + tin: true, + primaryEmail: true, +}); + +export type QuickCustomerCreateForm = z.infer; diff --git a/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-result.entity.ts b/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-result.entity.ts index 7139bf10..5e734498 100644 --- a/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-result.entity.ts +++ b/modules/customers/src/common/features/quick-create-customer/entities/quick-customer-create-result.entity.ts @@ -1,17 +1,7 @@ +import type { CustomerSelectionOption } from "../../customer-selection"; + /** - * Resultado simplificado del alta rápida. - * Compatible con CustomerSelectionOption para integración directa. + * El resultado público de alta rápida coincide con el contrato estable + * reutilizado por la selección de cliente. */ -export interface QuickCustomerCreateResult { - id: string; - status: string; - - isCompany?: boolean; - name: string; - tin: string; - - EmailPrimary: string; - - languageCode: string; - currencyCode: string; -} +export type QuickCustomerCreateResult = CustomerSelectionOption; diff --git a/modules/customers/src/common/features/quick-create-customer/index.ts b/modules/customers/src/common/features/quick-create-customer/index.ts index 60664d4e..074d31d6 100644 --- a/modules/customers/src/common/features/quick-create-customer/index.ts +++ b/modules/customers/src/common/features/quick-create-customer/index.ts @@ -1,2 +1,3 @@ -export * from "./entities"; -export * from "./utils"; +export * from "./controllers/use-quick-create-customer-flow"; +export * from "./entities/quick-customer-create-result.entity"; +export * from "./ui"; diff --git a/modules/customers/src/web/features/quick-create-customer/ui/blocks/index.ts b/modules/customers/src/common/features/quick-create-customer/ui/blocks/index.ts similarity index 100% rename from modules/customers/src/web/features/quick-create-customer/ui/blocks/index.ts rename to modules/customers/src/common/features/quick-create-customer/ui/blocks/index.ts diff --git a/modules/customers/src/common/features/quick-create-customer/ui/blocks/quick-customer-create-form-fields.tsx b/modules/customers/src/common/features/quick-create-customer/ui/blocks/quick-customer-create-form-fields.tsx new file mode 100644 index 00000000..cdf65eab --- /dev/null +++ b/modules/customers/src/common/features/quick-create-customer/ui/blocks/quick-customer-create-form-fields.tsx @@ -0,0 +1,135 @@ +import { + FormFieldLabel, + RdxEditorSection, + RdxFormGrid, + TextField, +} from "@repo/rdx-ui/components"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldLabel, + RadioGroup, + RadioGroupItem, +} from "@repo/shadcn-ui/components"; +import { AtSignIcon, Building2Icon, UserIcon } from "lucide-react"; +import { Controller, useFormContext } from "react-hook-form"; + +import { useTranslation } from "../../../../../web/i18n"; + +type QuickCustomerCreateFormFieldsProps = { + disabled?: boolean; +}; + +export const QuickCustomerCreateFormFields = ({ + disabled = false, +}: QuickCustomerCreateFormFieldsProps) => { + const { t } = useTranslation(); + const { control } = useFormContext(); + + return ( +
+ + + + + + + ( + +
+ {t("fields.customer_type.label")} + + { + field.onChange(value); + }} + required + value={field.value} + > + + + + + + {t("fields.customer_type.options.company")} + + + + + + + + + + {t("fields.customer_type.options.individual")} + + + + +
+ {t("fields.customer_type.description")} + +
+ )} + /> +
+
+ + + + } + name="primaryEmail" + placeholder={t("fields.email_primary.placeholder")} + typePreset="email" + /> + + +
+ ); +}; diff --git a/modules/customers/src/common/features/quick-create-customer/ui/dialogs/index.ts b/modules/customers/src/common/features/quick-create-customer/ui/dialogs/index.ts new file mode 100644 index 00000000..2eb8ab81 --- /dev/null +++ b/modules/customers/src/common/features/quick-create-customer/ui/dialogs/index.ts @@ -0,0 +1 @@ +export * from "./quick-create-customer-dialog"; diff --git a/modules/customers/src/common/features/quick-create-customer/ui/dialogs/quick-create-customer-dialog.test.tsx b/modules/customers/src/common/features/quick-create-customer/ui/dialogs/quick-create-customer-dialog.test.tsx new file mode 100644 index 00000000..29781865 --- /dev/null +++ b/modules/customers/src/common/features/quick-create-customer/ui/dialogs/quick-create-customer-dialog.test.tsx @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { FormProvider, useForm } from "react-hook-form"; + +import type { QuickCustomerCreateForm } from "../../entities"; +import { buildQuickCustomerCreateDefaultValues } from "../../utils"; +import { QuickCreateCustomerDialog } from "./quick-create-customer-dialog"; + +const renderDialog = () => { + const Harness = () => { + const form = useForm({ + defaultValues: buildQuickCustomerCreateDefaultValues(), + }); + + return ( + + undefined, + closeDialog: () => undefined, + cancelDialog: () => undefined, + resetForm: () => undefined, + onSubmit: (event) => { + event.preventDefault(); + }, + }} + /> + + ); + }; + + return renderToStaticMarkup(); +}; + +test("renderiza el diálogo rápido con los campos mínimos reutilizables", () => { + const html = renderDialog(); + + assert.match(html, /id="quick-create-customer-form"/); + assert.match(html, /name="name"/); + assert.match(html, /name="tin"/); + assert.match(html, /name="primaryEmail"/); + assert.doesNotMatch(html, /name="country"/); + assert.doesNotMatch(html, /name="languageCode"/); + assert.doesNotMatch(html, /name="currencyCode"/); +}); diff --git a/modules/customers/src/web/features/quick-create-customer/ui/dialogs/quick-create-customer-dialog.tsx b/modules/customers/src/common/features/quick-create-customer/ui/dialogs/quick-create-customer-dialog.tsx similarity index 52% rename from modules/customers/src/web/features/quick-create-customer/ui/dialogs/quick-create-customer-dialog.tsx rename to modules/customers/src/common/features/quick-create-customer/ui/dialogs/quick-create-customer-dialog.tsx index 74b21ed2..4ab874f0 100644 --- a/modules/customers/src/web/features/quick-create-customer/ui/dialogs/quick-create-customer-dialog.tsx +++ b/modules/customers/src/common/features/quick-create-customer/ui/dialogs/quick-create-customer-dialog.tsx @@ -1,3 +1,4 @@ +import { ErrorAlert } from "@erp/core/components"; import { Button, Dialog, @@ -7,16 +8,13 @@ import { } from "@repo/shadcn-ui/components"; import { FormProvider } from "react-hook-form"; -import { useTranslation } from "../../../../i18n"; +import { useTranslation } from "../../../../../web/i18n.ts"; +import type { useQuickCreateCustomerDialogController } from "../../controllers"; +import { QuickCustomerCreateFormFields } from "../blocks"; -type QuickCreateCustomerDialogController = { - formId: string; - form: ReturnType; - isOpen: boolean; - closeDialog: () => void; - onSubmit: React.FormEventHandler; - isSubmitting: boolean; -}; +type QuickCreateCustomerDialogController = ReturnType< + typeof useQuickCreateCustomerDialogController +>; type QuickCreateCustomerDialogProps = { ctrl: QuickCreateCustomerDialogController; @@ -26,20 +24,32 @@ export const QuickCreateCustomerDialog = ({ ctrl }: QuickCreateCustomerDialogPro const { t } = useTranslation(); return ( - !open && ctrl.closeDialog()} open={ctrl.isOpen}> - + !open && ctrl.cancelDialog()} open={ctrl.isOpen}> + {t("dialogs.quick_create_customer.title")} -
- + + {ctrl.submitError ? ( + + ) : null} + +