Proforma update

This commit is contained in:
David Arranz 2026-04-10 16:22:09 +02:00
parent 2eab047e90
commit ec8e734851
45 changed files with 867 additions and 359 deletions

View File

@ -16,6 +16,9 @@ type SimpleSearchInputProps = {
onSearchChange: (value: string) => void;
loading?: boolean;
maxHistory?: number;
placeholder?: string;
autoFocus?: boolean;
};
const SEARCH_HISTORY_KEY = "search_history";
@ -27,6 +30,8 @@ export const SimpleSearchInput = ({
onSearchChange,
loading = false,
maxHistory = 8,
placeholder,
autoFocus = false,
}: SimpleSearchInputProps) => {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState(value);
@ -131,11 +136,14 @@ export const SimpleSearchInput = ({
<InputGroup className="bg-background">
<InputGroupInput
autoComplete="off"
autoFocus={autoFocus}
inputMode="search"
onChange={handleInputChange}
onFocus={() => history.length > 0 && setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={t("components.simple_search_input.search_placeholder", "Search...")}
placeholder={
placeholder || t("components.simple_search_input.search_placeholder", "Search...")
}
ref={inputRef}
spellCheck={false}
value={searchValue}

View File

@ -1 +1,2 @@
export * from "./proforma-to-proforma-update-form.adapter";
export * from "./proforma-to-selected-customer.adapter";

View File

@ -0,0 +1,20 @@
import type { CustomerSelectionOption } from "@erp/customers/common";
import type { Proforma } from "../../shared/entities";
export const mapProformaToSelectedCustomer = (
proforma?: Proforma | null
): CustomerSelectionOption | null => {
if (!proforma?.customerId) {
return null;
}
return {
id: proforma.customerId,
name: proforma.recipient.name ?? "",
tin: proforma.recipient.tin ?? "",
languageCode: proforma.languageCode ?? "",
currencyCode: proforma.currencyCode ?? "",
};
};

View File

@ -1,13 +1,14 @@
import { useHookForm } from "@erp/core/hooks";
import type { CustomerSelectionOption } from "@erp/customers";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { useEffect, useId, useMemo } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import type { UpdateProformaByIdParams } from "../../shared";
import type { Proforma } from "../../shared/entities";
import { useProformaGetQuery, useProformaUpdateMutation } from "../../shared/hooks";
import { mapProformaToProformaUpdateForm } from "../adapters";
import { mapProformaToProformaUpdateForm, mapProformaToSelectedCustomer } from "../adapters";
import {
type ProformaUpdateForm,
ProformaUpdateFormSchema,
@ -63,6 +64,8 @@ export const useUpdateProformaController = (
disabled: isLoading || isUpdating,
});
const [selectedCustomer, setSelectedCustomer] = useState<CustomerSelectionOption | null>(null);
useEffect(() => {
if (!proformaData) return;
@ -70,6 +73,8 @@ export const useUpdateProformaController = (
form.reset(mapProformaToProformaUpdateForm(proformaData), {
keepDirty: false, // <-- importante: no marca el form como "dirty" al cargar los datos reales
});
setSelectedCustomer(mapProformaToSelectedCustomer(proformaData));
}, [proformaData, form]);
/** Handlers */
@ -80,6 +85,27 @@ export const useUpdateProformaController = (
: defaultProformaUpdateForm;
form.reset(initialData, { keepDirty: false });
setSelectedCustomer(mapProformaToSelectedCustomer(proformaData));
};
const setCustomer = (customer: CustomerSelectionOption) => {
setSelectedCustomer(customer);
form.setValue("customerId", customer.id, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
};
const clearCustomer = () => {
setSelectedCustomer(null);
form.setValue("customerId", "", {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
};
const submitHandler = form.handleSubmit(
@ -104,6 +130,8 @@ export const useUpdateProformaController = (
keepDirty: false,
});
setSelectedCustomer(mapProformaToSelectedCustomer(proformaData));
if (options?.successToasts !== false) {
showSuccessToast(
t("proformas.update.success.title"),
@ -164,6 +192,10 @@ export const useUpdateProformaController = (
isUpdateError,
updateError,
selectedCustomer,
setCustomer,
clearCustomer,
// No devolver FormProvider, así el controller es más
// flexible y reusable (p.ej. para un modal)
// FormProvider,

View File

@ -1,4 +1,5 @@
import { useUrlParamId } from "@erp/core/hooks";
import { useCustomerSelectionFlow } from "@erp/customers/common";
import { useUpdateProformaController } from "./use-update-proforma-controller";
@ -7,7 +8,18 @@ export const useUpdateProformaPageController = () => {
const updateCtrl = useUpdateProformaController(proformaId);
const selectCustomerCtrl = useCustomerSelectionFlow({
defaultLanguageCode: updateCtrl.form.watch("languageCode"),
defaultCurrencyCode: updateCtrl.form.watch("currencyCode"),
onCustomerSelected: (customer) => {
console.log(customer);
alert("cliente seleccionado");
//updateCtrl.setCustomer(customer);
},
});
return {
updateCtrl,
selectCustomerCtrl,
};
};

View File

@ -1,4 +1,6 @@
export * from "./proforma-basic-info-fields";
export * from "./proforma-form-field-shell";
export * from "./proforma-header-fields-card";
export * from "./proforma-header-form-grid";
export * from "./proforma-section-card";
export * from "./selected-recipient-summary";

View File

@ -0,0 +1,55 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
type SelectedRecipientSummaryProps = {
recipient?: CustomerSelectionOption | null;
onChangeClick: () => void;
onCreateClick?: () => void;
};
export const SelectedRecipientSummary = ({
recipient,
onChangeClick,
onCreateClick,
}: SelectedRecipientSummaryProps) => {
const { t } = useTranslation();
return (
<div className="rounded-lg border p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium">{t("customers.selected_customer.title", "Cliente")}</p>
{recipient ? (
<div className="mt-2 space-y-1">
<p className="truncate font-medium">{recipient.name}</p>
{recipient.tin ? (
<p className="truncate text-sm text-muted-foreground">{recipient.tin}</p>
) : null}
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">
{t("customers.selected_customer.empty", "No hay ningún cliente seleccionado")}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{onCreateClick ? (
<Button onClick={onCreateClick} type="button" variant="outline">
{t("customers.selected_customer.new_customer", "Nuevo cliente")}
</Button>
) : null}
<Button onClick={onChangeClick} type="button" variant="outline">
{recipient
? t("customers.selected_customer.change", "Cambiar cliente")
: t("customers.selected_customer.select", "Seleccionar cliente")}
</Button>
</div>
</div>
</div>
);
};

View File

@ -1,2 +1,2 @@
export * from "./proforma-update-editor-form";
export * from "./proforma-update-header-editor";
export * from "./proforma-update-recipient-editor";

View File

@ -1,20 +1,28 @@
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-editor.tsx
import type { CustomerSelectionOption } from "@erp/customers";
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ProformaUpdateHeaderEditor } from ".";
import { ProformaUpdateRecipientEditor } from ".";
import { useTranslation } from "../../../../i18n";
import type { Proforma } from "../../../shared/entities";
import { ProformaHeaderFieldsCard } from "../blocks/proforma-header-fields-card";
import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor";
type ProformaUpdateEditorProps = {
formId: string;
proforma?: Proforma;
isSubmitting: boolean;
onSubmit: React.FormEventHandler<HTMLFormElement>;
onReset: () => void;
selectedCustomer?: CustomerSelectionOption | null;
onChangeCustomerClick: () => void;
onCreateCustomerClick: () => void;
className?: string;
};
@ -23,6 +31,9 @@ export const ProformaUpdateEditorForm = ({
isSubmitting,
onSubmit,
onReset,
selectedCustomer,
onChangeCustomerClick,
onCreateCustomerClick,
className,
}: ProformaUpdateEditorProps) => {
const { t } = useTranslation();
@ -30,16 +41,13 @@ export const ProformaUpdateEditorForm = ({
return (
<form onSubmit={onSubmit}>
<div className="space-y-6">
<ProformaUpdateHeaderEditor
currencyOptions={[]}
customerOptions={[]}
<ProformaUpdateHeaderEditor disabled={isSubmitting} />
<ProformaUpdateRecipientEditor
disabled={isSubmitting}
paymentMethodOptions={[]}
priceListOptions={[]}
salesPersonOptions={[]}
serieOptions={[]}
statusOptions={[]}
warehouseOptions={[]}
onChangeCustomerClick={onChangeCustomerClick}
onCreateCustomerClick={onCreateCustomerClick}
selectedCustomer={selectedCustomer}
/>
<div className="flex flex-col-reverse gap-3 border-t pt-4 sm:flex-row sm:justify-end">

View File

@ -1,355 +1,63 @@
import { DatePickerField, SelectField, TextField } from "@repo/rdx-ui/components";
import {
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from "@repo/shadcn-ui/components";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
import type { ProformaUpdateForm } from "../../entities";
import { ProformaFormFieldShell, ProformaHeaderFormGrid, ProformaSectionCard } from "../blocks";
interface SelectOption {
value: string;
label: string;
}
import { ProformaHeaderFormGrid, ProformaSectionCard } from "../blocks";
interface ProformaUpdateHeaderEditorProps {
statusOptions: SelectOption[];
serieOptions: SelectOption[];
customerOptions: SelectOption[];
currencyOptions: SelectOption[];
paymentMethodOptions: SelectOption[];
salesPersonOptions: SelectOption[];
warehouseOptions: SelectOption[];
priceListOptions: SelectOption[];
disabled?: boolean;
readOnly?: boolean;
}
export const ProformaUpdateHeaderEditor = ({
statusOptions,
serieOptions,
customerOptions,
currencyOptions,
paymentMethodOptions,
salesPersonOptions,
warehouseOptions,
priceListOptions,
disabled = false,
readOnly = false,
}: ProformaUpdateHeaderEditorProps) => {
const { t } = useTranslation();
const {
register,
control,
formState: { errors },
} = useFormContext<ProformaUpdateForm>();
const isFieldLocked = disabled || readOnly;
return (
<div className="space-y-6">
<ProformaSectionCard
description={t("form_groups.proformas.basic_info.description")}
title={t("form_groups.proformas.basic_info.title")}
>
<ProformaHeaderFormGrid>
<SelectField
className="md:col-span-2"
label={t("form_fields.proformas.series.label")}
name="series"
placeholder={t("form_fields.proformas.series.placeholder")}
/>
<ProformaSectionCard
description={t("form_groups.proformas.basic_info.description")}
title={t("form_groups.proformas.basic_info.title")}
>
<ProformaHeaderFormGrid>
<SelectField
className="md:col-span-2"
label={t("form_fields.proformas.series.label")}
name="series"
placeholder={t("form_fields.proformas.series.placeholder")}
/>
<DatePickerField
className="md:col-span-3"
label={t("form_fields.proformas.invoice_date.label")}
name="invoice_date"
placeholder={t("form_fields.proformas.invoice_date.placeholder")}
required
/>
<DatePickerField
className="md:col-span-3"
label={t("form_fields.proformas.invoice_date.label")}
name="invoice_date"
placeholder={t("form_fields.proformas.invoice_date.placeholder")}
required
/>
<DatePickerField
className="md:col-span-3"
label={t("form_fields.proformas.operation_date.label")}
name="operation_date"
placeholder={t("form_fields.proformas.operation_date.placeholder")}
/>
<DatePickerField
className="md:col-span-3"
label={t("form_fields.proformas.operation_date.label")}
name="operation_date"
placeholder={t("form_fields.proformas.operation_date.placeholder")}
/>
<TextField
className="md:col-span-4"
label={t("form_fields.proformas.reference.label")}
maxLength={256}
name="reference"
placeholder={t("form_fields.proformas.reference.placeholder")}
/>
<TextField
className="md:col-span-4"
label={t("form_fields.proformas.reference.label")}
maxLength={256}
name="reference"
placeholder={t("form_fields.proformas.reference.placeholder")}
/>
<TextField
className="md:col-span-12"
label={t("form_fields.proformas.description.label")}
maxLength={256}
name="description"
placeholder={t("form_fields.proformas.description.placeholder")}
/>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
<ProformaSectionCard
description={t("proformas.update.sections.customer_description")}
title={t("proformas.update.sections.customer")}
>
<ProformaHeaderFormGrid>
<ProformaFormFieldShell
error={errors.customer_id?.message}
htmlFor="customer_id"
label={t("proformas.fields.customer")}
required
span="lg"
>
<Controller
control={control}
name="customer_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="customer_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{customerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.customer_tax_id?.message}
htmlFor="customer_tax_id"
label={t("proformas.fields.customer_tax_id")}
span="sm"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="customer_tax_id"
{...register("customer_tax_id")}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.customer_reference?.message}
htmlFor="customer_reference"
label={t("proformas.fields.customer_reference")}
span="md"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="customer_reference"
{...register("customer_reference")}
/>
</ProformaFormFieldShell>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
<ProformaSectionCard
description={t("proformas.update.sections.commercial_description")}
title={t("proformas.update.sections.commercial")}
>
<ProformaHeaderFormGrid>
<ProformaFormFieldShell
error={errors.currency_code?.message}
htmlFor="currency_code"
label={t("proformas.fields.currency_code")}
required
span="sm"
>
<Controller
control={control}
name="currency_code"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="currency_code">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{currencyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.payment_method_id?.message}
htmlFor="payment_method_id"
label={t("proformas.fields.payment_method")}
span="md"
>
<Controller
control={control}
name="payment_method_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="payment_method_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{paymentMethodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.due_date?.message}
htmlFor="due_date"
label={t("proformas.fields.due_date")}
span="sm"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="due_date"
type="date"
{...register("due_date")}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.sales_person_id?.message}
htmlFor="sales_person_id"
label={t("proformas.fields.sales_person")}
span="md"
>
<Controller
control={control}
name="sales_person_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="sales_person_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{salesPersonOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.warehouse_id?.message}
htmlFor="warehouse_id"
label={t("proformas.fields.warehouse")}
span="md"
>
<Controller
control={control}
name="warehouse_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="warehouse_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{warehouseOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.price_list_id?.message}
htmlFor="price_list_id"
label={t("proformas.fields.price_list")}
span="md"
>
<Controller
control={control}
name="price_list_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="price_list_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{priceListOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
<ProformaSectionCard
description={t("proformas.update.sections.content_description")}
title={t("proformas.update.sections.content")}
>
<ProformaHeaderFormGrid>
<ProformaFormFieldShell
error={errors.subject?.message}
htmlFor="subject"
label={t("proformas.fields.subject")}
span="full"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="subject"
{...register("subject")}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.notes?.message}
htmlFor="notes"
label={t("proformas.fields.notes")}
span="full"
>
<Textarea
className="min-h-28 resize-y"
disabled={isFieldLocked}
id="notes"
{...register("notes")}
/>
</ProformaFormFieldShell>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
</div>
<TextField
className="md:col-span-12"
label={t("form_fields.proformas.description.label")}
maxLength={256}
name="description"
placeholder={t("form_fields.proformas.description.placeholder")}
/>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
);
};

View File

@ -0,0 +1,49 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
import type { ProformaUpdateForm } from "../../entities";
import { ProformaHeaderFormGrid, ProformaSectionCard, SelectedRecipientSummary } from "../blocks";
interface ProformaUpdateRecipientEditorProps {
disabled?: boolean;
readOnly?: boolean;
selectedCustomer?: CustomerSelectionOption | null;
onChangeCustomerClick: () => void;
onCreateCustomerClick: () => void;
}
export const ProformaUpdateRecipientEditor = ({
disabled = false,
readOnly = false,
selectedCustomer,
onChangeCustomerClick,
onCreateCustomerClick,
}: ProformaUpdateRecipientEditorProps) => {
const { t } = useTranslation();
const {
register,
control,
formState: { errors },
} = useFormContext<ProformaUpdateForm>();
const isFieldLocked = disabled || readOnly;
return (
<ProformaSectionCard
description={t("form_groups.proformas.customer.description")}
title={t("form_groups.proformas.customer.title")}
>
<ProformaHeaderFormGrid>
<SelectedRecipientSummary
onChangeClick={onChangeCustomerClick}
onCreateClick={onCreateCustomerClick}
recipient={selectedCustomer}
/>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
);
};

View File

@ -1,6 +1,7 @@
import { SpainTaxCatalogProvider } from "@erp/core";
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
import { SelectCustomerDialog } from "@erp/customers";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { Spinner } from "@repo/shadcn-ui/components";
import { useMemo } from "react";
@ -15,7 +16,7 @@ export const ProformaUpdatePage = () => {
const { t } = useTranslation();
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
const { updateCtrl } = useUpdateProformaPageController();
const { updateCtrl, selectCustomerCtrl } = useUpdateProformaPageController();
if (updateCtrl.isLoading) {
return <ProformaUpdateSkeleton />;
@ -95,14 +96,23 @@ export const ProformaUpdatePage = () => {
{updateCtrl.isLoading && <Spinner />}
{!updateCtrl.isLoading && (
<FormProvider {...updateCtrl.form}>
<ProformaUpdateEditorForm
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
<>
<FormProvider {...updateCtrl.form}>
<ProformaUpdateEditorForm
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
//onCreateCustomerClick={selectCustomerCtrl.createCtrl.openDialog}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
selectedCustomer={updateCtrl.selectedCustomer}
/>
</FormProvider>
<SelectCustomerDialog
ctrl={selectCustomerCtrl.selectCtrl}
//onCreateNewCustomerClick={selectCustomerCtrl.createCtrl.openDialog}
/>
</FormProvider>
</>
)}
</AppContent>
</UnsavedChangesProvider>

View File

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

View File

@ -0,0 +1,30 @@
import type { CustomerSelectionOption } from "../entities";
import { SelectCustomerDialog } from "../ui";
import { useSelectCustomerDialogController } from "./use-select-customer-dialog-controller";
export const useCustomerSelectionFlow = (options: {
onCustomerSelected: (customer: CustomerSelectionOption) => void;
defaultLanguageCode?: string;
defaultCurrencyCode?: string;
}) => {
const selectCtrl = useSelectCustomerDialogController({
onSelect: options.onCustomerSelected,
});
/*const createCtrl = useQuickCreateCustomerDialogController({
onCreated: (customer) => {
options.onCustomerSelected(customer);
selectCtrl.closeDialog();
},
defaultLanguageCode: options.defaultLanguageCode,
defaultCurrencyCode: options.defaultCurrencyCode,
});*/
return {
selectCtrl,
//createCtrl,
SelectCustomerDialog,
};
};

View File

@ -0,0 +1,97 @@
import { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react";
import { type ListCustomersByCriteriaParams, useCustomersListQuery } from "../../../../web/shared";
import { type CustomerSelectionOption, buildCustomerSelectionOption } from "../..";
type UseSelectCustomerDialogControllerOptions = {
onSelect(customer: CustomerSelectionOption): void;
};
export const useSelectCustomerDialogController = (
options: UseSelectCustomerDialogControllerOptions
) => {
const [isOpen, setIsOpen] = useState(false);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo<NonNullable<ListCustomersByCriteriaParams["criteria"]>>(
() => ({
q: debouncedQ || "",
pageNumber: pageIndex,
pageSize,
order: "desc",
orderBy: "name",
//filters: statusFilter === "all" ? [] : [{ field: "status", operator: "eq", value: statusFilter }],
}),
[debouncedQ, pageIndex, pageSize /*statusFilter*/]
);
const query = useCustomersListQuery({
criteria,
enabled: isOpen,
});
const setSearchValue = (value: string) => {
const nextValue = value.trim().replace(/\s+/g, " ");
setSearch((prev) => {
if (prev === nextValue) return prev;
// Sólo si la búsqueda realmente cambia,
// reseteamos la página a 0 para evitar inconsistencias
setPageIndex(0);
return nextValue;
});
};
const setPageSizeValue = (value: number) => {
setPageSize((prev) => {
if (prev === value) return prev;
// Sólo si el tamaño de página realmente cambia,
// reseteamos la página a 0 para evitar inconsistencias
setPageIndex(0);
return value;
});
};
const customers: CustomerSelectionOption[] =
query.data?.items?.map(buildCustomerSelectionOption) ?? [];
const openDialog = () => setIsOpen(true);
const closeDialog = () => setIsOpen(false);
const selectCustomer = (customer: CustomerSelectionOption) => {
options.onSelect(customer);
closeDialog();
};
return {
isOpen,
openDialog,
closeDialog,
selectCustomer,
customers,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refetch: query.refetch,
pageIndex,
pageSize,
setPageIndex,
setPageSize: setPageSizeValue,
search,
setSearchValue,
};
};

View File

@ -0,0 +1,9 @@
/**
* Criteria para búsqueda/paginación de clientes.
* Alineado con queries típicas del backend.
*/
export interface CustomerSelectionCriteria {
q: string;
pageNumber: number;
pageSize: number;
}

View File

@ -0,0 +1,29 @@
/**
* Representa un cliente seleccionable desde cualquier módulo.
* Debe ser lo suficientemente rico para mostrar en UI,
* pero sin arrastrar todo el shape de Customer.
*/
export interface CustomerSelectionOption {
id: string;
//status: string;
//isCompany: boolean;
name: string;
//tradeName: string;
tin: string;
/*street: string;
street2: string;
city: string;
province: string;
postalCode: string;
country: string;
*/
//emailPrimary: string;
//primaryPhone: string;
//primaryMobile: string;
languageCode: string;
currencyCode: string;
}

View File

@ -0,0 +1,2 @@
export * from "./customer-selection-criteria.entity";
export * from "./customer-selection-option.entity";

View File

@ -0,0 +1,4 @@
export * from "./controllers/use-customer-selection-flow";
export * from "./entities";
export * from "./ui";
export * from "./utils";

View File

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

View File

@ -0,0 +1,139 @@
import { SimpleSearchInput } from "@erp/core/components";
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { CustomerSelectionOption } from "../../../../../common/features";
import { useTranslation } from "../../../../../web/i18n";
import type { useSelectCustomerDialogController } from "../../controllers";
import { SelectCustomerEmptyCard } from "./select-customer-empty-card";
type SelectCustomerDialogController = ReturnType<typeof useSelectCustomerDialogController>;
/*export const useSelectCustomerDialogController = () => {
isOpen: boolean;
closeDialog: () => void;
search: string;
setSearchValue: (value: string) => void;
customers: CustomerSelectionOption[];
isLoading: boolean;
isFetching: boolean;
isErrror: boolean;
error: unknown;
selectCustomer: (customer: CustomerSelectionOption) => void;
};*/
type SelectCustomerDialogProps = {
ctrl: SelectCustomerDialogController;
onCreateNewCustomerClick?: () => void;
};
export const SelectCustomerDialog = ({
ctrl,
onCreateNewCustomerClick,
}: SelectCustomerDialogProps) => {
const { t } = useTranslation();
return (
<Dialog onOpenChange={(open) => !open && ctrl.closeDialog()} open={ctrl.isOpen}>
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t("customers.select_customer.title", "Seleccionar cliente")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<SimpleSearchInput
autoFocus
loading={ctrl.isLoading}
onSearchChange={ctrl.setSearchValue}
placeholder={t(
"customers.select_customer.search_placeholder",
"Buscar por nombre, NIF o email"
)}
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">
<CustomerSelectionList
customers={ctrl.customers}
isLoading={ctrl.isLoading}
onSelect={ctrl.selectCustomer}
/>
</div>
</DialogContent>
</Dialog>
);
};
type CustomerSelectionListProps = {
customers: CustomerSelectionOption[];
isLoading: boolean;
onSelect: (customer: CustomerSelectionOption) => void;
};
const CustomerSelectionList = ({ customers, isLoading, onSelect }: CustomerSelectionListProps) => {
const { t } = useTranslation();
//const columns = useCustomersGridColumns({});
if (isLoading) {
return (
<div className="space-y-2 py-2">
<div className="h-14 rounded-md border" />
<div className="h-14 rounded-md border" />
<div className="h-14 rounded-md border" />
</div>
);
}
if (customers.length === 0) {
return <SelectCustomerEmptyCard />;
}
return (
<ItemGroup>
{customers.map((customer) => (
<Item
asChild
className={cn("bg-muted/50 font-medium transition hover:text-primary hover:bg-muted")}
key={customer.id}
onClick={() => onSelect(customer)}
role="listitem"
>
<a href="#" rel="noopener noreferrer" target="_blank">
<ItemContent>
<ItemTitle className="line-clamp-1">{customer.name}</ItemTitle>
<ItemDescription>{customer.tin}</ItemDescription>
</ItemContent>
</a>
</Item>
))}
</ItemGroup>
);
};

View File

@ -0,0 +1,29 @@
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@repo/shadcn-ui/components";
import { UserSearchIcon } from "lucide-react";
import { useTranslation } from "../../../../../web/i18n";
export const SelectCustomerEmptyCard = () => {
const { t } = useTranslation();
return (
<Empty className="h-full bg-muted/30">
<EmptyHeader>
<EmptyMedia variant="icon">
<UserSearchIcon />
</EmptyMedia>
<EmptyTitle>
{t("customers.select_customer.empty", "No se han encontrado clientes")}
</EmptyTitle>
<EmptyDescription className="max-w-xs text-pretty">
You&apos;re all caught up. New notifications will appear here.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
};

View File

@ -0,0 +1 @@
export * from "./dialogs/select-customer-dialog";

View File

@ -0,0 +1,23 @@
import type { Customer, CustomerListRow } from "../../../../web/shared";
import type { CustomerSelectionOption } from "../entities";
/**
* Mapper explícito para desacoplar UI del shape de customers/shared.
*/
export const buildCustomerSelectionOption = (
customer: CustomerListRow | Customer
): CustomerSelectionOption => {
return {
id: customer.id,
status: customer.status,
isCompany: customer.isCompany,
name: customer.name,
tin: customer.tin,
EmailPrimary: customer.EmailPrimary,
languageCode: customer.languageCode,
currencyCode: customer.currencyCode,
};
};

View File

@ -0,0 +1 @@
export * from './build-customer-selection-option';

View File

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

View File

@ -0,0 +1,2 @@
export * from './quick-customer-create-form.entity';
export * from './quick-customer-create-result.entity';

View File

@ -0,0 +1,15 @@
/**
* 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;
}

View File

@ -0,0 +1,17 @@
/**
* Resultado simplificado del alta rápida.
* Compatible con CustomerSelectionOption para integración directa.
*/
export interface QuickCustomerCreateResult {
id: string;
status: string;
isCompany?: boolean;
name: string;
tin: string;
EmailPrimary: string;
languageCode: string;
currencyCode: string;
}

View File

@ -0,0 +1,2 @@
export * from "./entities";
export * from "./utils";

View File

@ -0,0 +1,20 @@
import type { CreateCustomerParams } from "../../../../web/shared";
import type { QuickCustomerCreateForm } from "../entities";
/**
* Builder explícito Form Params (regla del proyecto).
*/
export const buildCreateCustomerParamsFromQuickForm = (
form: QuickCustomerCreateForm
): 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,
},
};
};

View File

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

View File

@ -0,0 +1,2 @@
export * from './build-create-customer-params-from-quick-form';
export * from './build-quick-customer-create-default-values';

View File

@ -1 +1,2 @@
export * from "./dto";
export * from "./features";

View File

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

View File

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

View File

@ -0,0 +1,87 @@
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("customers.quick_create.success.title", "Cliente creado"),
t("customers.quick_create.success.message", "Se ha creado correctamente")
);
form.reset(
buildQuickCustomerCreateDefaultValues({
languageCode: options.defaultLanguageCode,
currencyCode: options.defaultCurrencyCode,
})
);
closeDialog();
} catch (error) {
const err = error instanceof Error ? error : new Error("Unknown error");
showErrorToast(
t("customers.quick_create.error.title", "Error al crear cliente"),
err.message
);
}
});
return {
formId,
form,
isOpen,
openDialog,
closeDialog,
onSubmit,
isSubmitting: isPending,
};
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,60 @@
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@repo/shadcn-ui/components";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
type QuickCreateCustomerDialogController = {
formId: string;
form: ReturnType<any>;
isOpen: boolean;
closeDialog: () => void;
onSubmit: React.FormEventHandler<HTMLFormElement>;
isSubmitting: boolean;
};
type QuickCreateCustomerDialogProps = {
ctrl: QuickCreateCustomerDialogController;
};
export const QuickCreateCustomerDialog = ({ ctrl }: QuickCreateCustomerDialogProps) => {
const { t } = useTranslation();
return (
<Dialog onOpenChange={(open) => !open && ctrl.closeDialog()} open={ctrl.isOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("customers.quick_create.title", "Nuevo cliente")}</DialogTitle>
</DialogHeader>
<FormProvider {...ctrl.form}>
<form className="space-y-6" id={ctrl.formId} onSubmit={ctrl.onSubmit}>
<QuickCustomerCreateFormFields />
<div className="flex items-center justify-end gap-2">
<Button
disabled={ctrl.isSubmitting}
onClick={ctrl.closeDialog}
type="button"
variant="outline"
>
{t("common.cancel", "Cancelar")}
</Button>
<Button disabled={ctrl.isSubmitting} type="submit">
{ctrl.isSubmitting
? t("common.saving", "Guardando...")
: t("customers.quick_create.submit", "Crear cliente")}
</Button>
</div>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};

View File

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

View File

@ -1,10 +1,11 @@
import { useUrlParamId } from "@erp/core/hooks";
import { useCustomerUpdateController } from "./use-customer-update.controller";
/**
* Hook para gestionar el controlador de la página de actualización de cliente.
*
* @returns
*
* @returns
*/
export const useCustomerUpdatePageController = () => {
@ -15,4 +16,4 @@ export const useCustomerUpdatePageController = () => {
return {
updateCtrl,
};
};
};

View File

@ -81,7 +81,7 @@ export const TextField = <TFormValues extends FieldValues>({
<InputGroup
className={cn(
"bg-muted/50 font-medium",
"bg-muted/50 font-medium transition",
"hover:border-ring hover:ring-ring/20 hover:ring-[3px]",
"focus-visible:border-ring focus-visible:ring-ring/60 focus-visible:ring-[3px]",
"placeholder:text-muted-foreground/50",