This commit is contained in:
David Arranz 2025-09-23 19:21:16 +02:00
parent c8e3191d05
commit 9683ee5102
23 changed files with 350 additions and 108 deletions

View File

@ -9,11 +9,11 @@ import {
import { useFormContext } from "react-hook-form";
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
import { CreateCustomerFormData } from "../../schemas";
import { CustomerFormData } from "../../schemas";
export const CustomerAdditionalConfigFields = () => {
const { t } = useTranslation();
const { control } = useFormContext<CreateCustomerFormData>();
const { control } = useFormContext<CustomerFormData>();
return (
<Card className='border-0 shadow-none'>

View File

@ -9,11 +9,11 @@ import {
import { useFormContext } from "react-hook-form";
import { COUNTRY_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
import { CreateCustomerFormData } from "../../schemas";
import { CustomerFormData } from "../../schemas";
export const CustomerAddressFields = () => {
const { t } = useTranslation();
const { control } = useFormContext<CreateCustomerFormData>();
const { control } = useFormContext<CustomerFormData>();
return (
<Card className='border-0 shadow-none'>

View File

@ -15,11 +15,11 @@ import {
} from "@repo/shadcn-ui/components";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CreateCustomerFormData } from "../../schemas";
import { CustomerFormData } from "../../schemas";
export const CustomerBasicInfoFields = () => {
const { t } = useTranslation();
const { control } = useFormContext<CreateCustomerFormData>();
const { control } = useFormContext<CustomerFormData>();
const isCompany = useWatch({
control,

View File

@ -1,8 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { FieldErrors, FormProvider, useForm } from "react-hook-form";
import { FieldErrors, useFormContext } from "react-hook-form";
import { useEffect } from "react";
import { CreateCustomerFormData, CreateCustomerFormSchema } from "../../schemas";
import { CustomerFormData } from "../../schemas";
import { FormDebug } from "../form-debug";
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
import { CustomerAddressFields } from "./customer-address-fields";
@ -11,57 +9,26 @@ import { CustomerContactFields } from "./customer-contact-fields";
interface CustomerFormProps {
formId: string;
initialValues: CreateCustomerFormData;
onSubmit: (data: CreateCustomerFormData) => void;
onError: (errors: FieldErrors<CreateCustomerFormData>) => void;
disabled?: boolean;
onDirtyChange: (isDirty: boolean) => void;
onSubmit: (data: CustomerFormData) => void;
onError: (errors: FieldErrors<CustomerFormData>) => void;
}
export function CustomerEditForm({
formId,
initialValues,
onSubmit,
onError,
disabled,
onDirtyChange,
}: CustomerFormProps) {
const form = useForm<CreateCustomerFormData>({
resolver: zodResolver(CreateCustomerFormSchema),
defaultValues: initialValues,
disabled,
});
const {
formState: { isDirty },
} = form;
useEffect(() => {
if (onDirtyChange) {
onDirtyChange(isDirty);
}
}, [isDirty, onDirtyChange]);
// Resetear el form si cambian los valores iniciales
useEffect(() => {
form.reset(initialValues);
}, [initialValues, form]);
export const CustomerEditForm = ({ formId, onSubmit, onError }: CustomerFormProps) => {
const form = useFormContext<CustomerFormData>();
return (
<FormProvider {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<div className='xl:flex xl:flex-row-reverse xl:items-start'>
<div className='w-full xl:w-6/12'>
<FormDebug />
</div>
<div className='w-full xl:grow'>
<CustomerBasicInfoFields />
<CustomerContactFields />
<CustomerAddressFields />
<CustomerAdditionalConfigFields />
</div>
<form id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<div className='xl:flex xl:flex-row-reverse xl:items-start'>
<div className='w-full xl:w-6/12'>
<FormDebug />
</div>
</form>
</FormProvider>
<div className='w-full xl:grow'>
<CustomerBasicInfoFields />
<CustomerContactFields />
<CustomerAddressFields />
<CustomerAdditionalConfigFields />
</div>
</div>
</form>
);
}
};

View File

@ -1,4 +1,5 @@
export * from "./use-create-customer-mutation";
export * from "./use-customer-form";
export * from "./use-customer-query";
export * from "./use-customers-context";
export * from "./use-customers-query";

View File

@ -2,11 +2,11 @@ import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateCustomerRequestSchema, CustomerCreationResponseDTO } from "../../common";
import { CreateCustomerFormData } from "../schemas";
import { CustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation";
type CreateCustomerPayload = {
data: CreateCustomerFormData;
data: CustomerFormData;
};
export function useCreateCustomerMutation() {

View File

@ -0,0 +1,36 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { CustomerFormData, CustomerFormSchema } from "../schemas";
type UseCustomerFormProps = {
initialValues: CustomerFormData;
disabled?: boolean;
onDirtyChange?: (isDirty: boolean) => void;
};
export function useCustomerForm({ initialValues, disabled, onDirtyChange }: UseCustomerFormProps) {
const form = useForm<CustomerFormData>({
resolver: zodResolver(CustomerFormSchema),
defaultValues: initialValues,
disabled,
});
const {
formState: { isDirty },
} = form;
// Avisar cuando cambia el dirty state
useEffect(() => {
if (onDirtyChange) {
onDirtyChange(isDirty);
}
}, [isDirty, onDirtyChange]);
// Resetear el form si cambian los valores iniciales
useEffect(() => {
form.reset(initialValues);
}, [initialValues, form]);
return form;
}

View File

@ -2,14 +2,14 @@ import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerByIdRequestDTO, UpdateCustomerByIdRequestSchema } from "../../common";
import { CreateCustomerFormData } from "../schemas";
import { CustomerFormData } from "../schemas";
import { CUSTOMER_QUERY_KEY } from "./use-customer-query";
export const CUSTOMERS_LIST_KEY = ["customers"] as const;
type UpdateCustomerPayload = {
id: string;
data: CreateCustomerFormData;
data: CustomerFormData;
};
export function useUpdateCustomerMutation() {

View File

@ -3,17 +3,15 @@ import { useNavigate } from "react-router-dom";
import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { useState } from "react";
import { FieldErrors } from "react-hook-form";
import { FieldErrors, FormProvider } from "react-hook-form";
import { CustomerEditForm, ErrorAlert } from "../../components";
import { useCreateCustomerMutation } from "../../hooks";
import { useCreateCustomerMutation, useCustomerForm } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CreateCustomerFormData, defaultCustomerFormData } from "../../schemas";
import { CustomerFormData, defaultCustomerFormData } from "../../schemas";
export const CustomerCreate = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isDirty, setIsDirty] = useState(false);
// 1) Estado de creación (mutación)
const {
@ -23,23 +21,26 @@ export const CustomerCreate = () => {
error: createError,
} = useCreateCustomerMutation();
// 2) Submit con navegación condicionada por éxito
const handleSubmit = (formData: CreateCustomerFormData) => {
// 2) Form hook
const form = useCustomerForm({
initialValues: defaultCustomerFormData,
});
// 3) Submit con navegación condicionada por éxito
const handleSubmit = (formData: CustomerFormData) => {
mutate(
{ data: formData },
{
onSuccess(data) {
setIsDirty(false);
showSuccessToast(t("pages.create.successTitle"), t("pages.create.successMsg"));
// El timeout es para que a React le dé tiempo a procesar
// el cambio de estado de isDirty / setIsDirty.
setTimeout(() => {
navigate("/customers/list", {
state: { customerId: data.id, isNew: true },
replace: true,
});
}, 0);
// 🔹 reset limpia el form e isDirty pasa a false
form.reset(defaultCustomerFormData);
navigate("/customers/list", {
state: { customerId: data.id, isNew: true },
replace: true,
});
},
onError(error) {
showErrorToast(t("pages.create.errorTitle"), error.message);
@ -48,7 +49,7 @@ export const CustomerCreate = () => {
);
};
const handleError = (errors: FieldErrors<CreateCustomerFormData>) => {
const handleError = (errors: FieldErrors<CustomerFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
@ -57,7 +58,7 @@ export const CustomerCreate = () => {
<>
<AppBreadcrumb />
<AppContent>
<UnsavedChangesProvider isDirty={isDirty}>
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<div className='flex items-center justify-between space-y-4 px-6'>
<div className='space-y-2'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
@ -90,13 +91,13 @@ export const CustomerCreate = () => {
)}
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerEditForm
formId={"customer-create-form"} // para que el botón del header pueda hacer submit
initialValues={defaultCustomerFormData}
onSubmit={handleSubmit}
onError={handleError}
onDirtyChange={setIsDirty}
/>
<FormProvider {...form}>
<CustomerEditForm
formId='customer-create-form'
onSubmit={handleSubmit}
onError={handleError}
/>
</FormProvider>
</div>
</UnsavedChangesProvider>
</AppContent>

View File

@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
import { FormCommitButtonGroup, UnsavedChangesProvider, useUrlParamId } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { useState } from "react";
import { FieldErrors } from "react-hook-form";
import {
CustomerEditForm,
@ -11,15 +10,14 @@ import {
ErrorAlert,
NotFoundCard,
} from "../../components";
import { useCustomerQuery, useUpdateCustomerMutation } from "../../hooks";
import { useCustomerForm, useCustomerQuery, useUpdateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CreateCustomerFormData } from "../../schemas";
import { CustomerFormData } from "../../schemas";
export const CustomerUpdate = () => {
const customerId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
const [isDirty, setIsDirty] = useState(false);
// 1) Estado de carga del cliente (query)
const {
@ -37,8 +35,13 @@ export const CustomerUpdate = () => {
error: updateError,
} = useUpdateCustomerMutation();
// 3) Form hook
const form = useCustomerForm({
initialValues: customerData,
});
// 3) Submit con navegación condicionada por éxito
const handleSubmit = (formData: CreateCustomerFormData) => {
const handleSubmit = (formData: CustomerFormData) => {
mutate(
{ id: customerId!, data: formData },
{
@ -62,7 +65,7 @@ export const CustomerUpdate = () => {
);
};
const handleError = (errors: FieldErrors<CreateCustomerFormData>) => {
const handleError = (errors: FieldErrors<CustomerFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};

View File

@ -1,15 +1,15 @@
import * as z from "zod/v4";
import { CreateCustomerFormSchema } from "./customer-create.form.schema";
import { CustomerFormSchema } from "./customer.form.schema";
export const UpdateCustomerFormSchema = CreateCustomerFormSchema.extend({
is_company: CreateCustomerFormSchema.shape.is_company.optional(),
name: CreateCustomerFormSchema.shape.name.optional(),
export const UpdateCustomerFormSchema = CustomerFormSchema.extend({
is_company: CustomerFormSchema.shape.is_company.optional(),
name: CustomerFormSchema.shape.name.optional(),
default_taxes: z.array(z.string()).optional(),
country: CreateCustomerFormSchema.shape.country.optional(),
country: CustomerFormSchema.shape.country.optional(),
language_code: CreateCustomerFormSchema.shape.language_code.optional(),
currency_code: CreateCustomerFormSchema.shape.currency_code.optional(),
language_code: CustomerFormSchema.shape.language_code.optional(),
currency_code: CustomerFormSchema.shape.currency_code.optional(),
});
export type UpdateCustomerFormData = z.infer<typeof UpdateCustomerFormSchema>;

View File

@ -1,6 +1,6 @@
import * as z from "zod/v4";
export const CreateCustomerFormSchema = z.object({
export const CustomerFormSchema = z.object({
reference: z.string().optional(),
is_company: z.enum(["true", "false"]),
@ -55,9 +55,9 @@ export const CreateCustomerFormSchema = z.object({
.default("EUR"),
});
export type CreateCustomerFormData = z.infer<typeof CreateCustomerFormSchema>;
export type CustomerFormData = z.infer<typeof CustomerFormSchema>;
export const defaultCustomerFormData: CreateCustomerFormData = {
export const defaultCustomerFormData: CustomerFormData = {
reference: "",
is_company: "true",

View File

@ -1,2 +1,2 @@
export * from "./customer-create.form.schema";
export * from "./customer.api.schema";
export * from "./customer.form.schema";

View File

@ -0,0 +1,16 @@
{
"name": "@erp/verifactu",
"version": "0.0.1",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {},
"peerDependencies": {
"sequelize": "^6.37.5"
},
"devDependencies": {},
"dependencies": {
"@erp/core": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*"
}
}

View File

@ -1 +1 @@
export * from "./send-invoice-verifactu.use-case";
export * from "./send";

View File

@ -1,7 +1,8 @@
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { VerifactuRecordService } from "../../../domain/services/verifactu-record.service";
import { Transaction } from "sequelize";
import { VerifactuRecordService } from "../../../domain";
type SendInvoiceVerifactuUseCaseInput = {
invoice_id: string;
@ -24,7 +25,7 @@ export class SendInvoiceVerifactuUseCase {
}
const invoiceId = idOrError.data;
return this.transactionManager.complete(async (Transaction) => {
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const invoiceOrError = await this.service.sendInvoiceToVerifactu(invoiceId, transaction);
if (invoiceOrError.isFailure) {

View File

@ -0,0 +1,3 @@
export * from "./aggregates";
export * from "./repositories";
export * from "./services";

View File

@ -0,0 +1 @@
export * from "./verifactu-repository.interface";

View File

@ -0,0 +1 @@
export * from "./verifactu-record.service";

View File

@ -0,0 +1,161 @@
// modules/invoice/infrastructure/invoice-dependencies.factory.ts
import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api";
import {
InMemoryMapperRegistry,
InMemoryPresenterRegistry,
SequelizeTransactionManager,
} from "@erp/core/api";
import {
CreateCustomerInvoiceUseCase,
CustomerInvoiceFullPresenter,
CustomerInvoiceItemsFullPresenter,
CustomerInvoiceReportHTMLPresenter,
CustomerInvoiceReportPDFPresenter,
CustomerInvoiceReportPresenter,
GetCustomerInvoiceUseCase,
ListCustomerInvoicesPresenter,
ListCustomerInvoicesUseCase,
RecipientInvoiceFullPresenter,
ReportCustomerInvoiceUseCase,
} from "../application";
import { JsonTaxCatalogProvider, spainTaxCatalogProvider } from "@erp/core";
import { CustomerInvoiceItemsReportPersenter } from "../application/presenters/queries/customer-invoice-items.report.presenter";
import { CustomerInvoiceService } from "../domain";
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
import { CustomerInvoiceRepository } from "./sequelize";
export type CustomerInvoiceDeps = {
transactionManager: SequelizeTransactionManager;
mapperRegistry: IMapperRegistry;
presenterRegistry: IPresenterRegistry;
repo: CustomerInvoiceRepository;
service: CustomerInvoiceService;
catalogs: {
taxes: JsonTaxCatalogProvider;
};
build: {
list: () => ListCustomerInvoicesUseCase;
get: () => GetCustomerInvoiceUseCase;
create: () => CreateCustomerInvoiceUseCase;
//update: () => UpdateCustomerInvoiceUseCase;
//delete: () => DeleteCustomerInvoiceUseCase;
report: () => ReportCustomerInvoiceUseCase;
};
};
export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps {
const { database } = params;
const transactionManager = new SequelizeTransactionManager(database);
const catalogs = { taxes: spainTaxCatalogProvider };
// Mapper Registry
const mapperRegistry = new InMemoryMapperRegistry();
mapperRegistry
.registerDomainMapper(
{ resource: "customer-invoice" },
new CustomerInvoiceDomainMapper({ taxCatalog: catalogs.taxes })
)
.registerQueryMappers([
{
key: { resource: "customer-invoice", query: "LIST" },
mapper: new CustomerInvoiceListMapper(),
},
]);
// Repository & Services
const repo = new CustomerInvoiceRepository({ mapperRegistry, database });
const service = new CustomerInvoiceService(repo);
// Presenter Registry
const presenterRegistry = new InMemoryPresenterRegistry();
presenterRegistry.registerPresenters([
{
key: {
resource: "customer-invoice-items",
projection: "FULL",
},
presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry),
},
{
key: {
resource: "recipient-invoice",
projection: "FULL",
},
presenter: new RecipientInvoiceFullPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "FULL",
},
presenter: new CustomerInvoiceFullPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "LIST",
},
presenter: new ListCustomerInvoicesPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "REPORT",
format: "JSON",
},
presenter: new CustomerInvoiceReportPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice-items",
projection: "REPORT",
format: "JSON",
},
presenter: new CustomerInvoiceItemsReportPersenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "REPORT",
format: "HTML",
},
presenter: new CustomerInvoiceReportHTMLPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "REPORT",
format: "PDF",
},
presenter: new CustomerInvoiceReportPDFPresenter(presenterRegistry),
},
]);
return {
transactionManager,
repo,
mapperRegistry,
presenterRegistry,
service,
catalogs,
build: {
list: () => new ListCustomerInvoicesUseCase(service, transactionManager, presenterRegistry),
get: () => new GetCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
create: () =>
new CreateCustomerInvoiceUseCase(
service,
transactionManager,
presenterRegistry,
catalogs.taxes
),
// update: () => new UpdateCustomerInvoiceUseCase(service, transactionManager),
// delete: () => new DeleteCustomerInvoiceUseCase(service, transactionManager),
report: () =>
new ReportCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
},
};
}

View File

@ -0,0 +1,3 @@
export * from "./mappers";
export * from "./sequelize";
export * from "./express";

View File

@ -0,0 +1,33 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/customer-invoices/*": ["./src/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -657,6 +657,21 @@ importers:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.0.3)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.0)(stylus@0.62.0)(terser@5.40.0)(tsx@4.19.4)
modules/verifactu:
dependencies:
'@erp/core':
specifier: workspace:*
version: link:../core
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd
'@repo/rdx-utils':
specifier: workspace:*
version: link:../../packages/rdx-utils
sequelize:
specifier: ^6.37.5
version: 6.37.7(mysql2@3.14.1)
packages/rdx-criteria:
dependencies:
'@codelytv/criteria':
@ -12806,7 +12821,7 @@ snapshots:
wkx@0.5.0:
dependencies:
'@types/node': 24.0.3
'@types/node': 22.15.32
wordwrap@1.0.0: {}