feat(customers): implement reusable quick create customer feature

- Refactor QuickCustomerCreateResult to extend CustomerSelectionOption for better integration.
- Update index exports to include new controller and UI components for quick customer creation.
- Enhance buildCreateCustomerParamsFromQuickForm utility to support additional context defaults.
- Create QuickCustomerCreateFormSchema for validation of quick create form fields.
- Develop QuickCreateCustomerDialog component for user interaction.
- Implement useQuickCreateCustomerFlow hook to manage dialog state and submission logic.
- Add tests for form schema validation and parameter building utilities.
- Document the quick create customer feature for public API usage.
This commit is contained in:
David Arranz 2026-08-11 09:45:26 +02:00
parent c28a5e02c3
commit b07eb454eb
35 changed files with 988 additions and 205 deletions

View File

@ -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 = () => {
/>
</div>
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />
<SelectCustomerDialog
ctrl={selectCustomerCtrl.selectCtrl}
onCreateNewCustomerClick={selectCustomerCtrl.openQuickCreateFromSelection}
/>
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} />
</UnsavedChangesProvider>
</FormProvider>
</div>

View File

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

View File

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

View File

@ -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<string, unknown> | undefined;
let capturedSelectDialogProps: Record<string, unknown> | undefined;
let capturedQuickCreateDialogProps: Record<string, unknown> | 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<string, unknown>) => {
capturedQuickCreateDialogProps = props;
return null;
},
SelectCustomerDialog: (props: Record<string, unknown>) => {
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<string, unknown>) => {
capturedEditorProps = props;
return null;
},
},
});
const { ProformaUpdatePage } = await import("./proforma-update-page.tsx");
renderToStaticMarkup(<ProformaUpdatePage />);
assert.equal(capturedEditorProps?.onChangeCustomerClick, openSelectDialog);
assert.equal(capturedEditorProps?.onCreateCustomerClick, openQuickCreateDialog);
assert.equal(
capturedSelectDialogProps?.onCreateNewCustomerClick,
openQuickCreateFromSelection
);
assert.equal(capturedQuickCreateDialogProps?.ctrl, quickCreateCtrl);
mock.restoreAll();
});

View File

@ -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 = () => {
/>
</div>
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />
<SelectCustomerDialog
ctrl={selectCustomerCtrl.selectCtrl}
onCreateNewCustomerClick={selectCustomerCtrl.openQuickCreateFromSelection}
/>
<QuickCreateCustomerDialog ctrl={selectCustomerCtrl.createCtrl} />
</UnsavedChangesProvider>
</FormProvider>
</div>

View File

@ -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<typeof quickCreateOptions>
) => {
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<typeof useCustomerSelectionFlow>
| undefined;
const Harness = () => {
flow = useCustomerSelectionFlow({
onCustomerSelected: (customer) => {
selectedCustomers.push(customer as { id: string });
},
});
return null;
};
renderToStaticMarkup(<Harness />);
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();
});

View File

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

View File

@ -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 (
<Dialog onOpenChange={(open) => !open && ctrl.closeDialog()} open={ctrl.isOpen}>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-3xl">
@ -59,14 +65,6 @@ export const SelectCustomerDialog = ({
)}
value={ctrl.search}
/>
<div className="flex items-center justify-end">
{onCreateNewCustomerClick ? (
<Button onClick={onCreateNewCustomerClick} type="button" variant="outline">
{t("customers.select_customer.new_customer", "Nuevo cliente")}
</Button>
) : null}
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
@ -78,13 +76,22 @@ export const SelectCustomerDialog = ({
/>
</div>
<DialogFooter className="gap-2">
<Button onClick={ctrl.closeDialog} variant="outline">
Cancelar
</Button>
<Button disabled={ctrl.isLoading || !selectedCustomer} onClick={handleConfirm}>
{selectedCustomer ? "Confirmar selección" : "Seleccionar cliente"}
</Button>
<DialogFooter className="gap-2 flex items-center sm:justify-between">
<div className="flex items-center">
{onCreateNewCustomerClick ? (
<Button onClick={onCreateNewCustomerClick} type="button" variant="outline">
{t("customers.select_customer.new_customer", "Nuevo cliente")}
</Button>
) : null}
</div>
<div className="flex items-center gap-2">
<Button onClick={ctrl.closeDialog} variant="outline">
Cancelar
</Button>
<Button disabled={ctrl.isLoading || !selectedCustomer} onClick={handleConfirm}>
{selectedCustomer ? "Confirmar selección" : "Seleccionar cliente"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -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);
},
});
<Button onClick={quickCreate.createCtrl.openDialog}>Crear cliente</Button>
<QuickCreateCustomerDialog ctrl={quickCreate.createCtrl} />;
```
## 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.

View File

@ -0,0 +1,2 @@
export * from "./use-quick-create-customer-dialog-controller";
export * from "./use-quick-create-customer-flow";

View File

@ -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<Error | null>(null);
const {
mutateAsync,
isPending: isSubmitting,
isError: isCreateError,
} = useCustomerCreateMutation();
const form = useHookForm<QuickCustomerCreateForm>({
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<QuickCustomerCreateForm>) => {
console.error(errors);
setSubmitError(null);
focusFirstInputFormError(form);
showWarningToast(
t("common.feedback.validation.title"),
t("common.feedback.validation.message")
);
}
);
const onSubmit = (event: React.SubmitEvent<HTMLFormElement>) => {
event.stopPropagation();
submitHandler(event);
};
return {
formId,
form,
isOpen,
isSubmitting,
isCreateError,
submitError,
openDialog,
closeDialog,
cancelDialog,
resetForm,
onSubmit,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<div className="flex min-w-0 flex-col gap-6">
<RdxEditorSection
description={t("form_groups.basic_info.description")}
title={t("form_groups.basic_info.title")}
>
<RdxFormGrid className="md:grid-cols-2">
<TextField
description={t("fields.name.description")}
disabled={disabled}
label={t("fields.name.label")}
name="name"
placeholder={t("fields.name.placeholder")}
required
/>
<TextField
description={t("fields.tin.description")}
disabled={disabled}
label={t("fields.tin.label")}
name="tin"
placeholder={t("fields.tin.placeholder")}
/>
<Controller
control={control}
name="isCompany"
render={({ field, fieldState }) => (
<Field className="md:col-span-2" data-invalid={fieldState.invalid}>
<div className="flex flex-col gap-2 pt-1">
<FormFieldLabel required>{t("fields.customer_type.label")}</FormFieldLabel>
<RadioGroup
className="gap-3 md:grid md:grid-cols-2"
disabled={field.disabled || disabled}
name={field.name}
onValueChange={(value: boolean) => {
field.onChange(value);
}}
required
value={field.value}
>
<Field data-invalid={fieldState.invalid} orientation="horizontal">
<FieldContent>
<FieldLabel
className="cursor-pointer text-sm font-medium leading-none"
htmlFor="quick-customer-type-company"
>
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="quick-customer-type-company"
value={true}
/>
<Building2Icon className="size-4 text-muted-foreground" />
{t("fields.customer_type.options.company")}
</FieldLabel>
</FieldContent>
</Field>
<Field data-invalid={fieldState.invalid} orientation="horizontal">
<FieldContent>
<FieldLabel
className="cursor-pointer text-sm font-medium leading-none"
htmlFor="quick-customer-type-individual"
>
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="quick-customer-type-individual"
value={false}
/>
<UserIcon className="size-4 text-muted-foreground" />
{t("fields.customer_type.options.individual")}
</FieldLabel>
</FieldContent>
</Field>
</RadioGroup>
</div>
<FieldDescription>{t("fields.customer_type.description")}</FieldDescription>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</RdxFormGrid>
</RdxEditorSection>
<RdxEditorSection
description={t("form_groups.contact_info.description")}
title={t("form_groups.contact_info.title")}
>
<RdxFormGrid>
<TextField
className="md:col-span-6"
description={t("fields.email_primary.description")}
disabled={disabled}
label={t("fields.email_primary.label")}
leftIcon={<AtSignIcon className="size-4" strokeWidth={1.5} />}
name="primaryEmail"
placeholder={t("fields.email_primary.placeholder")}
typePreset="email"
/>
</RdxFormGrid>
</RdxEditorSection>
</div>
);
};

View File

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

View File

@ -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<QuickCustomerCreateForm>({
defaultValues: buildQuickCustomerCreateDefaultValues(),
});
return (
<FormProvider {...form}>
<QuickCreateCustomerDialog
ctrl={{
formId: "quick-create-customer-form",
form,
isOpen: true,
isSubmitting: false,
isCreateError: false,
submitError: null,
openDialog: () => undefined,
closeDialog: () => undefined,
cancelDialog: () => undefined,
resetForm: () => undefined,
onSubmit: (event) => {
event.preventDefault();
},
}}
/>
</FormProvider>
);
};
return renderToStaticMarkup(<Harness />);
};
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"/);
});

View File

@ -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<any>;
isOpen: boolean;
closeDialog: () => void;
onSubmit: React.FormEventHandler<HTMLFormElement>;
isSubmitting: boolean;
};
type QuickCreateCustomerDialogController = ReturnType<
typeof useQuickCreateCustomerDialogController
>;
type QuickCreateCustomerDialogProps = {
ctrl: QuickCreateCustomerDialogController;
@ -26,20 +24,32 @@ export const QuickCreateCustomerDialog = ({ ctrl }: QuickCreateCustomerDialogPro
const { t } = useTranslation();
return (
<Dialog onOpenChange={(open) => !open && ctrl.closeDialog()} open={ctrl.isOpen}>
<DialogContent className="sm:max-w-2xl">
<Dialog onOpenChange={(open) => !open && ctrl.cancelDialog()} open={ctrl.isOpen}>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("dialogs.quick_create_customer.title")}</DialogTitle>
</DialogHeader>
<FormProvider {...ctrl.form}>
<form className="space-y-6" id={ctrl.formId} onSubmit={ctrl.onSubmit}>
<QuickCustomerCreateFormFields />
<form
className="min-h-0 space-y-6 overflow-y-auto pr-1"
id={ctrl.formId}
noValidate
onSubmit={ctrl.onSubmit}
>
{ctrl.submitError ? (
<ErrorAlert
message={ctrl.submitError.message || t("errors.unexpected")}
title={t("toasts.quick_create_customer.error.title")}
/>
) : null}
<QuickCustomerCreateFormFields disabled={ctrl.isSubmitting} />
<div className="flex items-center justify-end gap-2">
<Button
disabled={ctrl.isSubmitting}
onClick={ctrl.closeDialog}
onClick={ctrl.cancelDialog}
type="button"
variant="outline"
>

View File

@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildCreateCustomerParamsFromQuickForm } from "./build-create-customer-params-from-quick-form";
test("construye el request oficial de customers a partir del formulario rápido", () => {
const params = buildCreateCustomerParamsFromQuickForm("bf0d3f59-7b96-4ab6-a74b-2f2b8bcd0fb5", {
isCompany: false,
name: "Jane Doe",
tin: "",
primaryEmail: "jane@example.com",
});
assert.equal(params.id, "bf0d3f59-7b96-4ab6-a74b-2f2b8bcd0fb5");
assert.deepEqual(params.data, {
id: "bf0d3f59-7b96-4ab6-a74b-2f2b8bcd0fb5",
is_company: false,
name: "Jane Doe",
address: {
country: "ES",
},
contact: {
email_primary: "jane@example.com",
},
uses_equivalence_surcharge: false,
uses_retention: false,
language_code: "es",
currency_code: "EUR",
});
});
test("permite sobrescribir los defaults de contexto sin pedirlos al usuario", () => {
const params = buildCreateCustomerParamsFromQuickForm("bf0d3f59-7b96-4ab6-a74b-2f2b8bcd0fb5", {
isCompany: true,
name: "Acme",
tin: "B12345678",
primaryEmail: "",
}, {
country: "pt",
languageCode: "EN",
currencyCode: "usd",
});
assert.equal(params.id, "bf0d3f59-7b96-4ab6-a74b-2f2b8bcd0fb5");
assert.deepEqual(params.data, {
id: "bf0d3f59-7b96-4ab6-a74b-2f2b8bcd0fb5",
is_company: true,
name: "Acme",
tin: "B12345678",
address: {
country: "PT",
},
uses_equivalence_surcharge: false,
uses_retention: false,
language_code: "en",
currency_code: "USD",
});
});

View File

@ -1,20 +1,61 @@
import { buildCustomerCreateDefault } from "../../../../web/create/utils/build-customer-create-defaults";
import { toNullableText, toRequiredText } from "@erp/core/client";
import type { CreateCustomerParams } from "../../../../web/shared";
import type { QuickCustomerCreateForm } from "../entities";
type QuickCustomerCreateContextDefaults = {
country?: string;
languageCode?: string;
currencyCode?: string;
};
/**
* Builder explícito Form → Params (regla del proyecto).
* Builder explícito Form → Params reutilizando la misma normalización
* de creación sin depender de componentes o páginas concretas.
*/
export const buildCreateCustomerParamsFromQuickForm = (
form: QuickCustomerCreateForm
customerId: string,
form: QuickCustomerCreateForm,
contextDefaults?: QuickCustomerCreateContextDefaults
): CreateCustomerParams => {
return {
data: {
name: form.name,
is_company: form.isCompany ? "1" : "0",
tin: form.tin,
email_primary: form.EmailPrimary,
language_code: form.languageCode,
currency_code: form.currencyCode,
const createDefaults = buildCustomerCreateDefault();
const resolvedCountry = toRequiredText(
contextDefaults?.country ?? createDefaults.country
).toUpperCase();
const resolvedLanguageCode = toRequiredText(
contextDefaults?.languageCode ?? createDefaults.languageCode
).toLowerCase();
const resolvedCurrencyCode = toRequiredText(
contextDefaults?.currencyCode ?? createDefaults.currencyCode
).toUpperCase();
const data: CreateCustomerParams["data"] = {
id: customerId,
is_company: form.isCompany,
name: toRequiredText(form.name),
language_code: resolvedLanguageCode,
currency_code: resolvedCurrencyCode,
uses_equivalence_surcharge: createDefaults.usesEquivalenceSurcharge,
uses_retention: createDefaults.usesRetention,
address: {
country: resolvedCountry,
},
};
const tin = toNullableText(form.tin);
if (tin !== null) {
data.tin = tin;
}
const primaryEmail = toNullableText(form.primaryEmail);
if (primaryEmail !== null) {
data.contact = {
email_primary: primaryEmail,
};
}
return {
id: customerId,
data,
};
};

View File

@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildQuickCustomerCreateDefaultValues } from "./build-quick-customer-create-default-values";
test("reutiliza los defaults oficiales de creación para los campos visibles", () => {
const defaults = buildQuickCustomerCreateDefaultValues();
assert.deepEqual(defaults, {
isCompany: true,
name: "",
tin: "",
primaryEmail: "",
});
});

View File

@ -1,19 +1,13 @@
import type { QuickCustomerCreateForm } from "../entities";
import { buildCustomerCreateDefault } from "../../../../web/create/utils/build-customer-create-defaults";
export const buildQuickCustomerCreateDefaultValues = (): QuickCustomerCreateForm => {
const defaults = buildCustomerCreateDefault();
export const buildQuickCustomerCreateDefaultValues = (
overrides?: Partial<QuickCustomerCreateForm>
): QuickCustomerCreateForm => {
return {
isCompany: true,
name: "",
tradeName: "",
tin: "",
EmailPrimary: "",
languageCode: overrides?.languageCode ?? "es",
currencyCode: overrides?.currencyCode ?? "EUR",
...overrides,
isCompany: defaults.isCompany,
name: defaults.name,
tin: defaults.tin,
primaryEmail: "",
};
};

View File

@ -1,2 +1,2 @@
export * from "./customer-selection";
export * from "./quick-create-customer";
export * from "../../common/features/customer-selection";
export * from "../../common/features/quick-create-customer";

View File

@ -1 +0,0 @@
export * from './use-quick-create-customer-dialog-controller';

View File

@ -1,87 +0,0 @@
import { useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useId, useState } from "react";
import {
type CustomerSelectionOption,
type QuickCustomerCreateForm,
buildCreateCustomerParamsFromQuickForm,
buildCustomerSelectionOption,
buildQuickCustomerCreateDefaultValues,
} from "../../../../common/features";
import { useTranslation } from "../../../i18n";
import { useCustomerCreateMutation } from "../../../shared/hooks";
type UseQuickCreateCustomerDialogControllerOptions = {
onCreated(customer: CustomerSelectionOption): void;
defaultLanguageCode?: string;
defaultCurrencyCode?: string;
};
export const useQuickCreateCustomerDialogController = (
options: UseQuickCreateCustomerDialogControllerOptions
) => {
const { t } = useTranslation();
const formId = useId();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isPending } = useCustomerCreateMutation();
const form = useHookForm<QuickCustomerCreateForm>({
initialValues: buildQuickCustomerCreateDefaultValues({
languageCode: options.defaultLanguageCode,
currencyCode: options.defaultCurrencyCode,
}),
});
const openDialog = () => setIsOpen(true);
const closeDialog = () => {
setIsOpen(false);
};
const onSubmit = form.handleSubmit(async (formData) => {
try {
const params = buildCreateCustomerParamsFromQuickForm(formData);
const created = await mutateAsync(params);
const option = buildCustomerSelectionOption(created);
options.onCreated(option);
showSuccessToast(
t("toasts.quick_create_customer.success.title"),
t("toasts.quick_create_customer.success.message")
);
form.reset(
buildQuickCustomerCreateDefaultValues({
languageCode: options.defaultLanguageCode,
currencyCode: options.defaultCurrencyCode,
})
);
closeDialog();
} catch (error) {
const err = error instanceof Error ? error : new Error("Unknown error");
showErrorToast(
t("toasts.quick_create_customer.error.title"),
err.message
);
}
});
return {
formId,
form,
isOpen,
openDialog,
closeDialog,
onSubmit,
isSubmitting: isPending,
};
};

View File

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

View File

@ -1 +0,0 @@
export * from './quick-create-customer-dialog';

View File

@ -121,7 +121,6 @@ export function restoreCustomerListCaches(
export function syncCreatedCustomerCaches(queryClient: QueryClient, customer: Customer) {
setCustomerDetailCache(queryClient, customer.id, customer);
return invalidateCustomerListQueries(queryClient);
}
export function syncUpdatedCustomerCaches(queryClient: QueryClient, customer: Customer) {