This commit is contained in:
David Arranz 2026-06-25 13:49:59 +02:00
parent f6f57359d5
commit 1551d65f63
138 changed files with 2575 additions and 1187 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/factuges-server", "name": "@erp/factuges-server",
"version": "0.6.9", "version": "0.7.3",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsup src/index.ts --config tsup.config.ts", "build": "tsup src/index.ts --config tsup.config.ts",

View File

@ -36,7 +36,7 @@ export function getServiceScoped<T = unknown>(
if (!allowedDeps.includes(serviceModule)) { if (!allowedDeps.includes(serviceModule)) {
throw new Error( throw new Error(
`❌ Module "${requesterModule}" tried to access service "${name}" ` + `❌ Module "${requesterModule}" tried to access service "${name}" ` +
`without declaring dependency on "${serviceModule}"` `without declaring dependency on "${serviceModule}" (web/manifest.ts api/index.ts file)`
); );
} }
@ -49,7 +49,7 @@ export function getServiceScoped<T = unknown>(
function getService<T = unknown>(name: string): T { function getService<T = unknown>(name: string): T {
const service = services[name]; const service = services[name];
if (!service) { if (!service) {
throw new Error(`❌ Servicio "${name}" no encontrado.`); throw new Error(`❌ Service "${name}" not found.`);
} }
return service as T; return service as T;
} }

View File

@ -1,7 +1,7 @@
{ {
"name": "@erp/factuges-web", "name": "@erp/factuges-web",
"private": true, "private": true,
"version": "0.6.9", "version": "0.7.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/auth", "name": "@erp/auth",
"version": "0.6.9", "version": "0.7.3",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,7 +1,7 @@
{ {
"name": "@erp/catalogs", "name": "@erp/catalogs",
"description": "Catalogs module", "description": "Catalogs module",
"version": "0.6.9", "version": "0.7.3",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -3,7 +3,8 @@ import { Maybe, Result } from "@repo/rdx-utils";
import type { PaymentTermPublicModelMapper } from "../mappers"; import type { PaymentTermPublicModelMapper } from "../mappers";
import type { PaymentTermPublicModel } from "../models"; import type { PaymentTermPublicModel } from "../models";
import type { IPaymentTermRepository } from "../repositories"; import { IPaymentTermRepository } from '../../repositories';
export interface FindPaymentTermByIdInCompanyParams { export interface FindPaymentTermByIdInCompanyParams {
companyId: UniqueID; companyId: UniqueID;

View File

@ -10,6 +10,7 @@ import type {
export class TaxDefinitionPublicModelMapper { export class TaxDefinitionPublicModelMapper {
public toPublicModel(taxDefinition: TaxDefinition): TaxDefinitionPublicModel { public toPublicModel(taxDefinition: TaxDefinition): TaxDefinitionPublicModel {
console.log(taxDefinition.description)
return { return {
id: taxDefinition.id, id: taxDefinition.id,
companyId: Maybe.some(taxDefinition.companyId), companyId: Maybe.some(taxDefinition.companyId),

View File

@ -9,6 +9,8 @@ import {
EnablePaymentTermByIdUseCase, EnablePaymentTermByIdUseCase,
GetPaymentTermByIdUseCase, GetPaymentTermByIdUseCase,
ListPaymentTermsUseCase, ListPaymentTermsUseCase,
PaymentTermPublicFinder,
PaymentTermPublicModelMapper,
UpdatePaymentTermByIdUseCase, UpdatePaymentTermByIdUseCase,
buildPaymentTermCreator, buildPaymentTermCreator,
buildPaymentTermDeleter, buildPaymentTermDeleter,
@ -19,7 +21,6 @@ import {
buildPaymentTermUpdater, buildPaymentTermUpdater,
} from "../../../application/payment-terms"; } from "../../../application/payment-terms";
import type { IPaymentTermRepository } from "../../../application/payment-terms/repositories"; import type { IPaymentTermRepository } from "../../../application/payment-terms/repositories";
import { PaymentTermFinder } from "../../../application/payment-terms/services";
import { buildPaymentTermPersistenceMappers } from "./payment-term-persistence-mappers.di"; import { buildPaymentTermPersistenceMappers } from "./payment-term-persistence-mappers.di";
import { buildPaymentTermRepository } from "./payment-term-repositories.di"; import { buildPaymentTermRepository } from "./payment-term-repositories.di";
@ -110,8 +111,10 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm
export const buildPaymentTermsPublicServices = ( export const buildPaymentTermsPublicServices = (
_params: SetupParams, _params: SetupParams,
deps: PaymentTermsInternalDeps deps: PaymentTermsInternalDeps
): { finder: PaymentTermFinder } => { ): { finder: PaymentTermPublicFinder } => {
const mapper = new PaymentTermPublicModelMapper();
return { return {
finder: new PaymentTermFinder(deps.repository), finder: new PaymentTermPublicFinder({ repository: deps.repository, mapper }),
}; };
}; };

View File

@ -38,7 +38,11 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
const code = extractOrPushError(TaxDefinitionCodeVO.create(raw.code), "code", errors); const code = extractOrPushError(TaxDefinitionCodeVO.create(raw.code), "code", errors);
const name = extractOrPushError(TaxDefinitionNameVO.create(raw.name), "name", errors); const name = extractOrPushError(TaxDefinitionNameVO.create(raw.name), "name", errors);
const description = maybeFromNullableResult(raw.description, (v) => TextValue.create(v)); const description = extractOrPushError(
maybeFromNullableResult(raw.description, (v) => TextValue.create(v)),
"description",
errors
);
const rate = extractOrPushError( const rate = extractOrPushError(
TaxRateVO.create({ value: raw.rate_value, scale: raw.rate_scale }), TaxRateVO.create({ value: raw.rate_value, scale: raw.rate_scale }),
@ -60,19 +64,30 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
errors errors
); );
const jurisdictionRegionCode = maybeFromNullableResult(raw.jurisdiction_region_code, (v) => const jurisdictionRegionCode = extractOrPushError(
CountryRegionCode.create(v) maybeFromNullableResult(raw.jurisdiction_region_code, (v) => CountryRegionCode.create(v)),
"jurisdiction_region_code",
errors
); );
const taxScope = extractOrPushError(TaxScope.create(raw.tax_scope), "tax_scope", errors); const taxScope = extractOrPushError(TaxScope.create(raw.tax_scope), "tax_scope", errors);
const invoiceNote = maybeFromNullableResult(raw.invoice_note, (v) => TextValue.create(v)); const invoiceNote = extractOrPushError(
maybeFromNullableResult(raw.invoice_note, (v) => TextValue.create(v)),
"invoice_note",
errors
);
const allowedSurchargeCodes = maybeFromNullableResult(raw.allowed_surcharge_codes, (v) => { const allowedSurchargeCodes = extractOrPushError(
if (!Array.isArray(v)) return Result.fail(new Error("Invalid allowed_surcharge_codes")); maybeFromNullableResult(raw.allowed_surcharge_codes, (v) => {
console.log(v);
const values = v.split(";");
if (!Array.isArray(values))
return Result.fail(new Error("Invalid allowed_surcharge_codes"));
const arr: any[] = []; const arr: any[] = [];
for (const el of v) { for (const el of values) {
const _result = TaxDefinitionCodeVO.create(String(el)); const _result = TaxDefinitionCodeVO.create(String(el));
if (_result.isFailure) if (_result.isFailure)
return Result.fail(new Error("Invalid allowed_surcharge_codes element")); return Result.fail(new Error("Invalid allowed_surcharge_codes element"));
@ -80,13 +95,26 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
} }
return Result.ok(arr); return Result.ok(arr);
}); }),
"allowed_surcharge_codes",
errors
);
// valid from / to // valid from / to
const validFrom = maybeFromNullableResult(raw.valid_from, (v) => Result.ok(String(v))); const validFrom = extractOrPushError(
const validTo = maybeFromNullableResult(raw.valid_to, (v) => Result.ok(String(v))); maybeFromNullableResult(raw.valid_from, (v) => Result.ok(String(v))),
"valid_from",
errors
);
const validTo = extractOrPushError(
maybeFromNullableResult(raw.valid_to, (v) => Result.ok(String(v))),
"valid_to",
errors
);
if (errors.length > 0) { if (errors.length > 0) {
console.error(errors);
return Result.fail( return Result.fail(
new ValidationErrorCollection("TaxDefinition mapping failed [mapToDomain]", errors) new ValidationErrorCollection("TaxDefinition mapping failed [mapToDomain]", errors)
); );
@ -130,8 +158,8 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
(v) => v.toPrimitive(), (v) => v.toPrimitive(),
() => null () => null
), ),
rate_value: source.rate.toPrimitive(), rate_value: source.rate.toPrimitive().value,
rate_scale: (source.rate as any).scale ?? 2, rate_scale: source.rate.toPrimitive().scale,
tax_family: source.taxFamily.toPrimitive(), tax_family: source.taxFamily.toPrimitive(),
calculation_behavior: source.calculationBehavior.toPrimitive(), calculation_behavior: source.calculationBehavior.toPrimitive(),
jurisdiction_country_code: source.jurisdictionCountryCode.toPrimitive(), jurisdiction_country_code: source.jurisdictionCountryCode.toPrimitive(),
@ -145,7 +173,7 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
() => null () => null
), ),
allowed_surcharge_codes: source.allowedSurchargeCodes.match( allowed_surcharge_codes: source.allowedSurchargeCodes.match(
(arr) => arr.map((c) => c.toPrimitive()), (arr) => arr.map((c) => c.toPrimitive()).join(";"),
() => null () => null
), ),
is_system: source.isSystem, is_system: source.isSystem,

View File

@ -29,7 +29,7 @@ export class TaxDefinitionModel extends Model<
declare tax_scope: string; declare tax_scope: string;
declare invoice_note: CreationOptional<string | null>; declare invoice_note: CreationOptional<string | null>;
declare allowed_surcharge_codes: CreationOptional<object | null>; declare allowed_surcharge_codes: CreationOptional<string | null>;
declare is_system: boolean; declare is_system: boolean;
declare is_active: boolean; declare is_active: boolean;
@ -111,7 +111,7 @@ export default (database: Sequelize) => {
}, },
allowed_surcharge_codes: { allowed_surcharge_codes: {
type: DataTypes.JSON, type: DataTypes.STRING,
allowNull: true, allowNull: true,
defaultValue: null, defaultValue: null,
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/core", "name": "@erp/core",
"version": "0.6.9", "version": "0.7.3",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -12,7 +12,7 @@ export const globalErrorHandler = async (
res: Response, res: Response,
next: NextFunction next: NextFunction
) => { ) => {
//console.error(`❌ Global unhandled error: ${error.message}`); console.error(`❌ Global unhandled error: ${error.message}`);
// Si ya se envió una respuesta, delegamos al siguiente error handler // Si ya se envió una respuesta, delegamos al siguiente error handler
if (res.headersSent) { if (res.headersSent) {

View File

@ -16,3 +16,4 @@ export * from "./postal-code.dto";
export * from "./quantity.dto"; export * from "./quantity.dto";
export * from "./tin.dto"; export * from "./tin.dto";
export * from "./url.dto"; export * from "./url.dto";
export * from "./website.dto";

View File

@ -8,14 +8,13 @@ import { z } from "zod/v4";
* *
* Ejemplos válidos: * Ejemplos válidos:
* - "28013" (ES) * - "28013" (ES)
* - "SW1A 1AA" (UK)
* - "75008" (FR) * - "75008" (FR)
* - "10115" (DE) * - "10115" (DE)
*/ */
export const PostalCodeSchema = z export const PostalCodeSchema = z
.string() .string()
.min(1, "Postal code cannot be empty") .min(1, "Postal code cannot be empty")
.max(16, "Postal code too long") .max(5, "Postal code too long")
.regex(/^[A-Za-z0-9\- ]+$/, "Invalid postal code format"); .regex(/^[0-9\- ]+$/, "Invalid postal code format");
export type PostalCodeDTO = z.infer<typeof PostalCodeSchema>; export type PostalCodeDTO = z.infer<typeof PostalCodeSchema>;

View File

@ -0,0 +1,38 @@
import { z } from "zod/v4";
export const WebsiteSchema = z.preprocess(
(value) => {
if (typeof value !== "string") return value;
const trimmed = value.trim();
return trimmed === "" ? "" : trimmed;
},
z
.string()
.refine(
(value) => {
if (!value) return true;
const candidate = /^https?:\/\//i.test(value) ? value : `https://${value}`;
try {
const url = new URL(candidate);
return Boolean(url.hostname.includes("."));
} catch {
return false;
}
},
{
message: "Introduce una URL válida",
}
)
.transform((value) => {
if (!value) return "";
return /^https?:\/\//i.test(value) ? value : `https://${value}`;
})
);
export type WebsiteDTO = z.infer<typeof WebsiteSchema>;

View File

@ -1,4 +1,5 @@
export * from "./catalogs"; export * from "./catalogs";
export * from "./dto"; export * from "./dto";
export * from "./helpers"; export * from "./helpers";
export * from "./json-data";
export * from "./types"; export * from "./types";

View File

@ -0,0 +1,2 @@
export * from "./json-collection.provider";
export * from "./json-data.provider";

View File

@ -0,0 +1,45 @@
import { JsonDataProvider } from "./json-data.provider";
export abstract class JsonCollectionProvider<TItem> extends JsonDataProvider<readonly TItem[]> {
protected constructor(items: readonly TItem[]) {
super(Object.freeze([...items]));
}
/**
* Devuelve la colección original en modo de solo lectura.
*
* El provider no debe modificar datos de referencia cargados desde JSON.
*/
public getAll(): readonly TItem[] {
return this.data;
}
/**
* Construye un índice único y falla al cargar datos inconsistentes.
*
* No se permite que el último elemento sobrescriba silenciosamente al anterior,
* porque ocultaría errores en ficheros JSON de referencia.
*/
protected buildUniqueIndex(
indexName: string,
getKey: (item: TItem) => string
): ReadonlyMap<string, TItem> {
const index = new Map<string, TItem>();
for (const item of this.data) {
const key = getKey(item);
if (key.length === 0) {
throw new Error(`Invalid empty key for JSON index "${indexName}".`);
}
if (index.has(key)) {
throw new Error(`Duplicated key "${key}" for JSON index "${indexName}".`);
}
index.set(key, item);
}
return index;
}
}

View File

@ -0,0 +1,7 @@
export abstract class JsonDataProvider<TData> {
protected readonly data: TData;
protected constructor(data: TData) {
this.data = data;
}
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/customer-invoices", "name": "@erp/customer-invoices",
"version": "0.6.9", "version": "0.7.3",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,6 @@
import type { import type {
IPaymentMethodPublicFinder, IPaymentMethodPublicFinder,
IPaymentTermPublicFinder,
ITaxDefinitionPublicFinder, ITaxDefinitionPublicFinder,
ITaxRegimePublicFinder, ITaxRegimePublicFinder,
} from "@erp/catalogs/api"; } from "@erp/catalogs/api";
@ -19,6 +20,7 @@ export function buildProformaCatalogResolvers(params: {
taxDefinitionFinder: ITaxDefinitionPublicFinder; taxDefinitionFinder: ITaxDefinitionPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder; taxRegimeFinder: ITaxRegimePublicFinder;
paymentMethodFinder: IPaymentMethodPublicFinder; paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
}): IProformaCatalogResolvers { }): IProformaCatalogResolvers {
return { return {
taxResolver: buildProformaTaxResolver({ taxResolver: buildProformaTaxResolver({
@ -28,6 +30,7 @@ export function buildProformaCatalogResolvers(params: {
paymentResolver: new ProformaPaymentResolver({ paymentResolver: new ProformaPaymentResolver({
paymentMethodFinder: params.paymentMethodFinder, paymentMethodFinder: params.paymentMethodFinder,
paymentTermFinder: params.paymentTermFinder,
}), }),
}; };
} }

View File

@ -146,7 +146,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
companyId: params.companyId, companyId: params.companyId,
status: InvoiceStatus.draft(), status: InvoiceStatus.draft(),
invoiceNumber: invoiceNumber!, //invoiceNumber: invoiceNumber!,
series: series!, series: series!,
invoiceDate: invoiceDate!, invoiceDate: invoiceDate!,

View File

@ -12,7 +12,7 @@ import type { ProformaItemTaxCodesInput } from "./proforma-item-tax-codes-input.
* - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio. * - Se encargan de validar y convertir los datos recibidos en formatos adecuados para el dominio.
*/ */
export type ProformaCreateInputProps = Omit<IProformaCreateProps, "items"> & { export type ProformaCreateInputProps = Omit<IProformaCreateProps, "items" | "invoiceNumber"> & {
items: ProformaItemCreateInputProps[]; items: ProformaItemCreateInputProps[];
}; };

View File

@ -1,4 +1,4 @@
import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api"; import type { IPaymentMethodPublicFinder, IPaymentTermPublicFinder } from "@erp/catalogs/api";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils"; import { type Maybe, Result } from "@repo/rdx-utils";
@ -26,13 +26,10 @@ export class ProformaPaymentResolver {
public constructor( public constructor(
private readonly deps: { private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder; paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
} }
) {} ) {}
// Comprobar si existe un método de pago (boolean)
// Recuperar un método de pago si existe (Maybe)
// Solo asegurarnos que existe ese método de pago // Solo asegurarnos que existe ese método de pago
public async ensurePaymentMethodById(params: { public async ensurePaymentMethodById(params: {
companyId: UniqueID; companyId: UniqueID;
@ -59,4 +56,31 @@ export class ProformaPaymentResolver {
return Result.ok(undefined); return Result.ok(undefined);
} }
// Solo asegurarnos que existe ese método de pago
public async ensurePaymentTermById(params: {
companyId: UniqueID;
id: Maybe<UniqueID>;
}): Promise<Result<void, Error>> {
if (params.id.isNone()) {
return Result.ok(undefined);
}
const id = params.id.unwrap();
const result = await this.deps.paymentTermFinder.findByIdInCompany({
companyId: params.companyId,
id,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.fail(new Error(`Payment term not found: ${id.toString()}`));
}
return Result.ok(undefined);
}
} }

View File

@ -78,7 +78,7 @@ export class ProformaCreator implements IProformaCreator {
private async resolveCreateProps( private async resolveCreateProps(
props: ProformaCreateInputProps props: ProformaCreateInputProps
): Promise<Result<IProformaCreateProps, Error>> { ): Promise<Result<Omit<IProformaCreateProps, "invoiceNumber">, Error>> {
// Tax Regime => comprobar que existe // Tax Regime => comprobar que existe
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({ const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({
companyId: props.companyId, companyId: props.companyId,

View File

@ -74,6 +74,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
taxDefinitionFinder: catalogs.taxDefinition.finder, taxDefinitionFinder: catalogs.taxDefinition.finder,
taxRegimeFinder: catalogs.taxRegime.finder, taxRegimeFinder: catalogs.taxRegime.finder,
paymentMethodFinder: catalogs.paymentMethod.finder, paymentMethodFinder: catalogs.paymentMethod.finder,
paymentTermFinder: catalogs.paymentTerm.finder,
}); });
const readModelAssemblers = buildProformaReadModelAssemblers({ const readModelAssemblers = buildProformaReadModelAssemblers({
@ -82,6 +83,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
}); });
const proformaNumberService = buildProformaNumberGenerator(); const proformaNumberService = buildProformaNumberGenerator();
const finder = buildProformaFinder(repository); const finder = buildProformaFinder(repository);
const creator = buildProformaCreator({ const creator = buildProformaCreator({

View File

@ -11,6 +11,9 @@ type ProformaCatalogsDeps = {
paymentMethod: { paymentMethod: {
finder: CatalogsPublicServicesType["paymentMethods"]["finder"]; finder: CatalogsPublicServicesType["paymentMethods"]["finder"];
}; };
paymentTerm: {
finder: CatalogsPublicServicesType["paymentTerms"]["finder"];
};
}; };
export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatalogsDeps { export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatalogsDeps {
@ -35,6 +38,13 @@ export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatal
throw new Error("Missing public service: catalogs:paymentMethods.finder"); throw new Error("Missing public service: catalogs:paymentMethods.finder");
} }
const paymentTerm =
params.getService<CatalogsPublicServicesType["paymentTerms"]>("catalogs:paymentTerms");
if (!paymentTerm?.finder) {
throw new Error("Missing public service: catalogs:paymentTerms.finder");
}
return { return {
taxDefinition: { taxDefinition: {
finder: taxDefinition.finder, finder: taxDefinition.finder,
@ -45,5 +55,8 @@ export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatal
paymentMethod: { paymentMethod: {
finder: paymentMethod.finder, finder: paymentMethod.finder,
}, },
paymentTerm: {
finder: paymentTerm.finder,
},
}; };
} }

View File

@ -39,7 +39,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
private _recipientMapper: SequelizeProformaRecipientDomainMapper; private _recipientMapper: SequelizeProformaRecipientDomainMapper;
private _taxesMapper: SequelizeProformaTaxesDomainMapper; private _taxesMapper: SequelizeProformaTaxesDomainMapper;
constructor(params: MapperParamsType) { constructor(params?: MapperParamsType) {
super(); super();
this._itemsMapper = new SequelizeProformaItemDomainMapper(); this._itemsMapper = new SequelizeProformaItemDomainMapper();

View File

@ -57,7 +57,11 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
layout: "app-fullscreen", layout: "app-fullscreen",
protected: true, protected: true,
}, },
element: <ProformaUpdatePage />, element: (
<ProformaLayout>
<ProformaUpdatePage />
</ProformaLayout>
),
}, },
{ {

View File

@ -1,4 +1,4 @@
import type { IModuleClient } from "@erp/core/client"; import type { IModuleClient, ModuleClientParams } from "@erp/core/client";
import { CustomerInvoiceRoutes } from "./customer-invoice-routes"; import { CustomerInvoiceRoutes } from "./customer-invoice-routes";
@ -13,7 +13,7 @@ export const CustomerInvoicesModuleManifest: IModuleClient = {
protected: true, // protegido por defecto protected: true, // protegido por defecto
layout: "app-sidebar", // layout por defecto layout: "app-sidebar", // layout por defecto
routes: (params) => CustomerInvoiceRoutes(params), routes: (params: ModuleClientParams) => CustomerInvoiceRoutes(params),
}; };
export default CustomerInvoicesModuleManifest; export default CustomerInvoicesModuleManifest;

View File

@ -1,5 +1,9 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
export function ProformaLayout({ children }: PropsWithChildren) { export function ProformaLayout({ children }: PropsWithChildren) {
return <div className="flex flex-col h-full w-full">{children}</div>; return (
<div className="flex flex-col h-full w-full" id="proformas ">
{children}
</div>
);
} }

View File

@ -180,6 +180,7 @@ export const useUpdateProformaController = (
options?.onUpdated?.(updated); options?.onUpdated?.(updated);
} catch (error: unknown) { } catch (error: unknown) {
console.log(error);
const normalizedError = normalizeSubmitError(error); const normalizedError = normalizeSubmitError(error);
// No revierto el form para que no se pierdan los cambios que // No revierto el form para que no se pierdan los cambios que
@ -221,7 +222,6 @@ export const useUpdateProformaController = (
} }
}, },
(errors: FieldErrors<ProformaUpdateForm>) => { (errors: FieldErrors<ProformaUpdateForm>) => {
console.error(errors);
focusFirstInputFormError(form); focusFirstInputFormError(form);
showWarningToast( showWarningToast(

View File

@ -33,9 +33,6 @@ type ProformaUpdateEditorProps = {
totalsCtrl: UseUpdateProformaTotalsControllerResult; totalsCtrl: UseUpdateProformaTotalsControllerResult;
paymentCtrl: UseUpdateProformaPaymentControllerResult; paymentCtrl: UseUpdateProformaPaymentControllerResult;
currencyCode?: string;
languageCode?: string;
className?: string; className?: string;
}; };
@ -51,23 +48,16 @@ export const ProformaUpdateEditorForm = ({
taxCtrl, taxCtrl,
totalsCtrl, totalsCtrl,
paymentCtrl, paymentCtrl,
currencyCode,
languageCode,
className, className,
}: ProformaUpdateEditorProps) => { }: ProformaUpdateEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<form <div className={cn("p-6", className)}>
className={cn("space-y-6 2xl:space-y-12", className)} <form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
id={formId} <div className="grid grid-cols-1 2xl:grid-cols-4 gap-6 2xl:gap-12">
noValidate <div className="2xl:col-span-3 space-y-6 gap-6 2xl:gap-12 2xl:space-y-12">
onKeyDown={preventEnterKeySubmitForm} <div className="grid 2xl:grid-cols-3 gap-6 2xl:gap-6 space-y-12 2xl:space-y-12">
onSubmit={onSubmit}
>
<div className="grid grid-cols-1 2xl:grid-cols-4 gap-4 2xl:gap-6">
<div className="2xl:col-span-3 space-y-4 gap-4 2xl:gap-6 2xl:space-y-6">
<div className="grid 2xl:grid-cols-3 gap-4 2xl:gap-6 space-y-4 2xl:space-y-6">
<ProformaUpdateHeaderEditor className="2xl:col-span-full" disabled={isSubmitting} /> <ProformaUpdateHeaderEditor className="2xl:col-span-full" disabled={isSubmitting} />
</div> </div>
@ -78,7 +68,7 @@ export const ProformaUpdateEditorForm = ({
totalsCtrl={totalsCtrl} totalsCtrl={totalsCtrl}
/> />
</div> </div>
<div className="2xl:col-span-1 space-y-4 2xl:space-y-6"> <div className="2xl:col-span-1 space-y-6 2xl:space-y-12">
<ProformaUpdateRecipientEditor <ProformaUpdateRecipientEditor
className="2xl:col-span-1" className="2xl:col-span-1"
disabled={isSubmitting} disabled={isSubmitting}
@ -103,5 +93,6 @@ export const ProformaUpdateEditorForm = ({
</div> </div>
{/* Footer fijo con totales */} {/* Footer fijo con totales */}
</form> </form>
</div>
); );
}; };

View File

@ -47,7 +47,7 @@ export const ProformaUpdateHeaderEditor = ({
disabled={disabled} disabled={disabled}
label={t("form_fields.proformas.invoice_number.label")} label={t("form_fields.proformas.invoice_number.label")}
maxLength={16} maxLength={16}
name="reference" name="invoiceNumber"
placeholder={t("form_fields.proformas.invoice_number.placeholder")} placeholder={t("form_fields.proformas.invoice_number.placeholder")}
readOnly={true} readOnly={true}
rightIcon={ rightIcon={

View File

@ -101,12 +101,9 @@ export const ProformaUpdatePage = () => {
)} )}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ProformaUpdateEditorForm <ProformaUpdateEditorForm
className="p-6 space-y-6 "
currencyCode={updateCtrl.currencyCode}
formId={updateCtrl.formId} formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating} isSubmitting={updateCtrl.isUpdating}
itemsCtrl={updateCtrl.itemsCtrl} itemsCtrl={updateCtrl.itemsCtrl}
languageCode={updateCtrl.languageCode}
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog} onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
onCreateCustomerClick={() => null} onCreateCustomerClick={() => null}
onReset={updateCtrl.resetForm} onReset={updateCtrl.resetForm}

View File

@ -1,7 +1,7 @@
{ {
"name": "@erp/customers", "name": "@erp/customers",
"description": "Customers", "description": "Customers",
"version": "0.6.9", "version": "0.7.3",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -0,0 +1,36 @@
import type {
IPaymentMethodPublicFinder,
IPaymentTermPublicFinder,
ITaxDefinitionPublicFinder,
ITaxRegimePublicFinder,
} from "@erp/catalogs/api";
import {
CustomerPaymentResolver,
type CustomerTaxResolver,
buildCustomerTaxResolver,
} from "../services/catalog-resolver";
export interface ICustomerCatalogResolvers {
taxResolver: CustomerTaxResolver;
paymentResolver: CustomerPaymentResolver;
}
export function buildCustomerCatalogResolvers(params: {
taxDefinitionFinder: ITaxDefinitionPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
}): ICustomerCatalogResolvers {
return {
taxResolver: buildCustomerTaxResolver({
taxDefinitionFinder: params.taxDefinitionFinder,
taxRegimeFinder: params.taxRegimeFinder,
}),
paymentResolver: new CustomerPaymentResolver({
paymentMethodFinder: params.paymentMethodFinder,
paymentTermFinder: params.paymentTermFinder,
}),
};
}

View File

@ -1,5 +1,3 @@
import type { ICatalogs } from "@erp/core/api";
import { import {
CreateCustomerInputMapper, CreateCustomerInputMapper,
type ICreateCustomerInputMapper, type ICreateCustomerInputMapper,
@ -12,11 +10,9 @@ export interface ICustomerInputMappers {
updateInputMapper: IUpdateCustomerInputMapper; updateInputMapper: IUpdateCustomerInputMapper;
} }
export const buildCustomerInputMappers = (catalogs: ICatalogs): ICustomerInputMappers => { export const buildCustomerInputMappers = (): ICustomerInputMappers => {
const { taxCatalog } = catalogs;
// Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado // Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado
const createInputMapper = new CreateCustomerInputMapper({ taxCatalog }); const createInputMapper = new CreateCustomerInputMapper();
const updateInputMapper = new UpdateCustomerInputMapper(); const updateInputMapper = new UpdateCustomerInputMapper();
return { return {

View File

@ -0,0 +1,28 @@
import type {
IPaymentMethodPublicFinder,
IPaymentTermPublicFinder,
ITaxRegimePublicFinder,
} from "@erp/catalogs/api";
import {
CustomerFullReadModelAssembler,
type ICustomerFullReadModelAssembler,
} from "../services/assemblers";
export interface ICustomerReadModelAssemblers {
full: ICustomerFullReadModelAssembler;
}
export function buildCustomerReadModelAssemblers(params: {
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
}): ICustomerReadModelAssemblers {
return {
full: new CustomerFullReadModelAssembler({
paymentMethodFinder: params.paymentMethodFinder,
paymentTermFinder: params.paymentTermFinder,
taxRegimeFinder: params.taxRegimeFinder,
}),
};
}

View File

@ -2,6 +2,7 @@ import type { ITransactionManager } from "@erp/core/api";
import type { ICreateCustomerInputMapper, IUpdateCustomerInputMapper } from "../mappers"; import type { ICreateCustomerInputMapper, IUpdateCustomerInputMapper } from "../mappers";
import type { ICustomerCreator, ICustomerFinder, ICustomerUpdater } from "../services"; import type { ICustomerCreator, ICustomerFinder, ICustomerUpdater } from "../services";
import type { ICustomerFullReadModelAssembler } from "../services/assemblers";
import type { import type {
ICustomerFullSnapshotBuilder, ICustomerFullSnapshotBuilder,
ICustomerSummarySnapshotBuilder, ICustomerSummarySnapshotBuilder,
@ -15,10 +16,11 @@ import {
export function buildGetCustomerByIdUseCase(deps: { export function buildGetCustomerByIdUseCase(deps: {
finder: ICustomerFinder; finder: ICustomerFinder;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder; fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {
return new GetCustomerByIdUseCase(deps.finder, deps.fullSnapshotBuilder, deps.transactionManager); return new GetCustomerByIdUseCase(deps);
} }
export function buildListCustomersUseCase(deps: { export function buildListCustomersUseCase(deps: {
@ -36,29 +38,21 @@ export function buildListCustomersUseCase(deps: {
export function buildCreateCustomerUseCase(deps: { export function buildCreateCustomerUseCase(deps: {
creator: ICustomerCreator; creator: ICustomerCreator;
dtoMapper: ICreateCustomerInputMapper; dtoMapper: ICreateCustomerInputMapper;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder; fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {
return new CreateCustomerUseCase({ return new CreateCustomerUseCase(deps);
dtoMapper: deps.dtoMapper,
creator: deps.creator,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
transactionManager: deps.transactionManager,
});
} }
export function buildUpdateCustomerUseCase(deps: { export function buildUpdateCustomerUseCase(deps: {
updater: ICustomerUpdater; updater: ICustomerUpdater;
dtoMapper: IUpdateCustomerInputMapper; dtoMapper: IUpdateCustomerInputMapper;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder; fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {
return new UpdateCustomerUseCase({ return new UpdateCustomerUseCase(deps);
dtoMapper: deps.dtoMapper,
updater: deps.updater,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
transactionManager: deps.transactionManager,
});
} }
/*export function buildReportCustomerUseCase(deps: { /*export function buildReportCustomerUseCase(deps: {

View File

@ -1,6 +1,8 @@
export * from "./customer-catalog-resolvers.di";
export * from "./customer-creator.di"; export * from "./customer-creator.di";
export * from "./customer-finder.di"; export * from "./customer-finder.di";
export * from "./customer-input-mappers.di"; export * from "./customer-input-mappers.di";
export * from "./customer-read-model-assemblers.di";
export * from "./customer-snapshot-builders.di"; export * from "./customer-snapshot-builders.di";
export * from "./customer-updater.di"; export * from "./customer-updater.di";
export * from "./customer-use-cases.di"; export * from "./customer-use-cases.di";

View File

@ -1,4 +1,3 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { import {
City, City,
Country, Country,
@ -35,12 +34,6 @@ export interface ICreateCustomerInputMapper {
} }
export class CreateCustomerInputMapper implements ICreateCustomerInputMapper { export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
private readonly taxCatalog: JsonTaxCatalogProvider;
constructor(params: { taxCatalog: JsonTaxCatalogProvider }) {
this.taxCatalog = params.taxCatalog;
}
public map( public map(
dto: CreateCustomerRequestDTO, dto: CreateCustomerRequestDTO,
params: { companyId: UniqueID } params: { companyId: UniqueID }
@ -52,7 +45,7 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
const customerId = extractOrPushError(UniqueID.create(dto.id), "id", errors); const customerId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
const status = CustomerStatus.createActive(); const status = CustomerStatus.createActive();
const isCompany = dto.is_company === "true"; const isCompany = Boolean(dto.is_company);
const reference = extractOrPushError( const reference = extractOrPushError(
maybeFromNullableResult(dto.reference, (value) => Name.create(value)), maybeFromNullableResult(dto.reference, (value) => Name.create(value)),
@ -164,6 +157,27 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
errors errors
); );
const paymentMethodId = extractOrPushError(
maybeFromNullableResult(dto.payment_method_id, (value) => UniqueID.create(value)),
"payment_method_id",
errors
);
const paymentTermId = extractOrPushError(
maybeFromNullableResult(dto.payment_term_id, (value) => UniqueID.create(value)),
"payment_term_id",
errors
);
const taxRegimeCode = extractOrPushError(
maybeFromNullableResult(dto.tax_regime_code, (value) => Result.ok(String(value))),
"tax_regime_code",
errors
);
const usesEquivalenceSurcharge = Boolean(dto.uses_equivalence_surcharge);
const usesRetention = Boolean(dto.uses_retention);
const languageCode = extractOrPushError( const languageCode = extractOrPushError(
LanguageCode.create(dto.language_code), LanguageCode.create(dto.language_code),
"language_code", "language_code",
@ -178,15 +192,6 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
const defaultTaxes: TaxCode[] = []; const defaultTaxes: TaxCode[] = [];
/*if (!isNullishOrEmpty(dto.default_taxes)) {
dto.default_taxes!.map((taxCode, index) => {
const tax = extractOrPushError(TaxCode.create(taxCode), `default_taxes.${index}`, errors);
if (tax) {
defaultTaxes.add(tax!);
}
});
}*/
if (errors.length > 0) { if (errors.length > 0) {
return Result.fail(new ValidationErrorCollection("Customer props mapping failed", errors)); return Result.fail(new ValidationErrorCollection("Customer props mapping failed", errors));
} }
@ -225,7 +230,14 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
website: website!, website: website!,
legalRecord: legalRecord!, legalRecord: legalRecord!,
defaultTaxes: defaultTaxes!,
paymentMethodId: paymentMethodId!,
paymentTermId: paymentTermId!,
taxRegimeCode: taxRegimeCode!,
usesEquivalenceSurcharge: usesEquivalenceSurcharge,
usesRetention: usesRetention,
languageCode: languageCode!, languageCode: languageCode!,
currencyCode: currencyCode!, currencyCode: currencyCode!,
}; };

View File

@ -1,2 +1,3 @@
export * from "./create-customer-input.mapper"; export * from "./create-customer-input.mapper";
export * from "./tax-definition-to-tax.mapper";
export * from "./update-customer-input.mapper"; export * from "./update-customer-input.mapper";

View File

@ -0,0 +1,44 @@
import type { TaxDefinitionPublicFamily, TaxDefinitionPublicModel } from "@erp/catalogs/api";
import { Tax, type TaxGroup, TaxPercentage } from "@erp/core/api";
import { Result } from "@repo/rdx-utils";
/**
* Sirve para mapear TaxDefinitionPublicModel a Tax,
* que es el modelo de dominio que se para proforma e issued-invoices.
*/
export class TaxDefinitionToTaxMapper {
public toTax(taxDefinition: TaxDefinitionPublicModel): Result<Tax> {
const group = this.toTaxGroup(taxDefinition.taxFamily);
if (group.isFailure) {
return Result.fail(group.error);
}
return Tax.create({
code: taxDefinition.code,
name: taxDefinition.name,
rate: TaxPercentage.create({
value: taxDefinition.rate.value,
}).data,
group: group.data,
calculationBehavior: taxDefinition.calculationBehavior,
});
}
private toTaxGroup(taxFamily: TaxDefinitionPublicFamily): Result<TaxGroup> {
switch (taxFamily) {
case "iva":
case "igic":
case "ipsi":
case "retention":
return Result.ok(taxFamily);
case "surcharge":
return Result.ok("surcharge");
default:
return Result.fail(new Error(`Unsupported tax family: ${String(taxFamily)}`));
}
}
}

View File

@ -14,7 +14,7 @@ import {
TINNumber, TINNumber,
TextValue, TextValue,
URLAddress, URLAddress,
type UniqueID, UniqueID,
ValidationErrorCollection, ValidationErrorCollection,
type ValidationErrorDetail, type ValidationErrorDetail,
extractOrPushError, extractOrPushError,
@ -115,30 +115,55 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
); );
}); });
/** toPatchField(dto.payment_method_id).ifSetOrNull((paymentMethodId) => {
* Se mantiene la compatibilidad con el contrato actual. customerPatchProps.paymentMethodId = extractOrPushError(
* Cuando defaultTaxes tenga un contrato final estable, aquí conviene maybeFromNullableResult(paymentMethodId, (value) => UniqueID.create(value)),
* mapearlo a CustomerTaxes/Collection<TaxCode> con su schema semántico real. "payment_method_id",
*/ errors
toPatchField(dto.default_taxes).ifSet((_defaultTaxes) => { );
errors.push({
path: "default_taxes",
message: "default_taxes mapping is not implemented yet",
});
}); });
toPatchField(dto.payment_method_id).ifSetOrNull((paymentMethodId) => {
customerPatchProps.paymentMethodId = extractOrPushError(
maybeFromNullableResult(paymentMethodId, (value) => UniqueID.create(value)),
"payment_method_id",
errors
);
});
toPatchField(dto.tax_regime_code).ifSetOrNull((taxRegimeCode) => {
console.log("Hay taxRegimeCode", taxRegimeCode);
customerPatchProps.taxRegimeCode = extractOrPushError(
maybeFromNullableResult(taxRegimeCode, (value) => Result.ok(String(value))),
"tax_regime_code",
errors
);
});
toPatchField(dto.uses_equivalence_surcharge).ifSet((usesEquivalenceSurcharge) => {
customerPatchProps.usesEquivalenceSurcharge = usesEquivalenceSurcharge;
});
toPatchField(dto.uses_retention).ifSet((usesRetention) => {
customerPatchProps.usesRetention = usesRetention;
});
this.throwIfValidationErrors(errors);
const addressPatchProps = this.mapPostalAddress(dto.address, errors); const addressPatchProps = this.mapPostalAddress(dto.address, errors);
this.throwIfValidationErrors(errors);
if (addressPatchProps) { if (addressPatchProps) {
customerPatchProps.address = addressPatchProps; customerPatchProps.address = addressPatchProps;
} }
this.mapContact(dto.contact, customerPatchProps, errors); this.mapContact(dto.contact, customerPatchProps, errors);
if (errors.length > 0) { this.throwIfValidationErrors(errors);
return Result.fail(
new ValidationErrorCollection("Customer props mapping failed (update)", errors) console.log("dto => ", dto);
); console.log("customerPatchProps => ", customerPatchProps);
}
return Result.ok(customerPatchProps); return Result.ok(customerPatchProps);
} catch (err: unknown) { } catch (err: unknown) {
@ -280,4 +305,10 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
); );
}); });
} }
private throwIfValidationErrors(errors: ValidationErrorDetail[]): void {
if (errors.length > 0) {
throw new ValidationErrorCollection("Proforma props mapping failed", errors);
}
}
} }

View File

@ -0,0 +1,16 @@
import type { ITaxDefinitionPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api";
import { TaxDefinitionToTaxMapper } from "../../mappers";
import { CustomerTaxResolver } from "./customer-tax-resolver";
export function buildCustomerTaxResolver(deps: {
taxDefinitionFinder: ITaxDefinitionPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
}): CustomerTaxResolver {
return new CustomerTaxResolver({
taxDefinitionFinder: deps.taxDefinitionFinder,
taxRegimeFinder: deps.taxRegimeFinder,
taxMapper: new TaxDefinitionToTaxMapper(),
});
}

View File

@ -0,0 +1,56 @@
import type { IPaymentMethodPublicFinder, IPaymentTermPublicFinder } from "@erp/catalogs/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
export interface EnsureCustomerPaymentMethodByIdParams {
companyId: UniqueID;
id: Maybe<UniqueID>;
}
/**
* Resuelve formas de pago externas al agregado de customer usando servicios públicos de `catalogs`.
*
* Este servicio pertenece a Application porque coordina dependencias entre módulos
* antes de crear o actualizar una customer.
*
* No accede a repositorios de `catalogs`, no usa Sequelize y no debe utilizarse
* desde mappers de persistencia. Su responsabilidad es convertir contratos públicos
* de catálogos (`payment-methods`) en Value Objects propios del
* dominio de `customer`.
*
*/
export class CustomerPaymentResolver {
public constructor(
private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
}
) {}
// Solo asegurarnos que existe ese plazo de apgo
public async ensurePaymentTermById(params: {
companyId: UniqueID;
id: Maybe<UniqueID>;
}): Promise<Result<void, Error>> {
if (params.id.isNone()) {
return Result.ok(undefined);
}
const id = params.id.unwrap();
const result = await this.deps.paymentTermFinder.findByIdInCompany({
companyId: params.companyId,
id,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.fail(new Error(`Payment term not found: ${id.toString()}`));
}
return Result.ok(undefined);
}
}

View File

@ -0,0 +1,59 @@
import type { ITaxDefinitionPublicFinder, ITaxRegimePublicFinder } from "@erp/catalogs/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { TaxDefinitionToTaxMapper } from "../../mappers";
export interface EnsureCustomerTaxRegimeByCodeParams {
companyId: UniqueID;
code: Maybe<string>;
}
/**
* Resuelve datos fiscales externos al agregado de customer usando servicios públicos de `catalogs`.
*
* Este servicio pertenece a Application porque coordina dependencias entre módulos
* antes de crear o actualizar una customer.
*
* No accede a repositorios de `catalogs`, no usa Sequelize y no debe utilizarse
* desde mappers de persistencia. Su responsabilidad es convertir contratos públicos
* de catálogos (`tax-definitions` y `tax-regimes`) en Value Objects propios del
* dominio de `customer`.
*
*/
export class CustomerTaxResolver {
public constructor(
private readonly deps: {
taxDefinitionFinder: ITaxDefinitionPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
taxMapper: TaxDefinitionToTaxMapper;
}
) {}
// Solo asegurarnos que existe un TaxRegime con ese "code"
public async ensureTaxRegimeByCode(params: {
companyId: UniqueID;
code: Maybe<string>;
}): Promise<Result<void, Error>> {
if (params.code.isNone()) {
return Result.ok(undefined);
}
const code = params.code.unwrap();
const result = await this.deps.taxRegimeFinder.findByCodeInCompany({
companyId: params.companyId,
code,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.fail(new Error(`Tax regime not found: ${code}`));
}
return Result.ok(undefined);
}
}

View File

@ -0,0 +1,3 @@
export * from "./build-customer-tax-resolver";
export * from "./customer-payment-resolver";
export * from "./customer-tax-resolver";

View File

@ -5,6 +5,7 @@ import { Result } from "@repo/rdx-utils";
import type { CreateCustomerRequestDTO } from "../../../common"; import type { CreateCustomerRequestDTO } from "../../../common";
import type { ICreateCustomerInputMapper } from "../mappers"; import type { ICreateCustomerInputMapper } from "../mappers";
import type { ICustomerCreator } from "../services"; import type { ICustomerCreator } from "../services";
import type { ICustomerFullReadModelAssembler } from "../services/assemblers";
import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders"; import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders";
type CreateCustomerUseCaseInput = { type CreateCustomerUseCaseInput = {
@ -12,47 +13,46 @@ type CreateCustomerUseCaseInput = {
dto: CreateCustomerRequestDTO; dto: CreateCustomerRequestDTO;
}; };
type CreateCustomerUseCaseDeps = { export class CreateCustomerUseCase {
constructor(
private readonly deps: {
dtoMapper: ICreateCustomerInputMapper; dtoMapper: ICreateCustomerInputMapper;
creator: ICustomerCreator; creator: ICustomerCreator;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder; fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
};
export class CreateCustomerUseCase {
private readonly dtoMapper: ICreateCustomerInputMapper;
private readonly creator: ICustomerCreator;
private readonly fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
private readonly transactionManager: ITransactionManager;
constructor(deps: CreateCustomerUseCaseDeps) {
this.dtoMapper = deps.dtoMapper;
this.creator = deps.creator;
this.fullSnapshotBuilder = deps.fullSnapshotBuilder;
this.transactionManager = deps.transactionManager;
} }
) {}
public execute(params: CreateCustomerUseCaseInput) { public execute(params: CreateCustomerUseCaseInput) {
const { dto, companyId } = params; const { dto, companyId } = params;
const mappedPropsResult = this.dtoMapper.map(dto, { companyId }); const mappedPropsResult = this.deps.dtoMapper.map(dto, { companyId });
if (mappedPropsResult.isFailure) { if (mappedPropsResult.isFailure) {
return mappedPropsResult; return mappedPropsResult;
} }
const { props, id } = mappedPropsResult.data; const { props, id } = mappedPropsResult.data;
return this.transactionManager.complete(async (transaction: unknown) => { return this.deps.transactionManager.complete(async (transaction: unknown) => {
try { try {
const createResult = await this.creator.create({ companyId, id, props, transaction }); const createResult = await this.deps.creator.create({ companyId, id, props, transaction });
if (createResult.isFailure) { if (createResult.isFailure) {
return createResult; return createResult;
} }
const newCustomer = createResult.data; const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId,
customer: createResult.data,
transaction,
});
const snapshot = this.fullSnapshotBuilder.toOutput(newCustomer); if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
const snapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data);
return Result.ok(snapshot); return Result.ok(snapshot);
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -1,4 +1,4 @@
export * from "./create-customer.use-case"; export * from "./create-customer.use-case";
export * from "./get-customer-by-id.use-case"; export * from "./get-customer-by-id.use-case";
export * from "./list-customers.use-case"; export * from "./list-customers.use-case";
export * from "./update/update-customer.use-case"; export * from "./update-customer.use-case";

View File

@ -0,0 +1,75 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { UpdateCustomerByIdRequestDTO } from "../../../common/dto";
import type { CustomerPatchProps } from "../../domain";
import type { IUpdateCustomerInputMapper } from "../mappers";
import type { ICustomerUpdater } from "../services";
import type { ICustomerFullReadModelAssembler } from "../services/assemblers";
import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders";
type UpdateCustomerUseCaseInput = {
companyId: UniqueID;
customer_id: string;
dto: UpdateCustomerByIdRequestDTO;
};
export class UpdateCustomerUseCase {
constructor(
private readonly deps: {
dtoMapper: IUpdateCustomerInputMapper;
updater: ICustomerUpdater;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}
) {}
public execute(params: UpdateCustomerUseCaseInput) {
const { companyId, customer_id, dto } = params;
const idOrError = UniqueID.create(customer_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const id = idOrError.data;
// Mapear DTO → props de dominio
const patchPropsResult = this.deps.dtoMapper.map(dto, { companyId });
if (patchPropsResult.isFailure) {
return patchPropsResult;
}
const patchProps: CustomerPatchProps = patchPropsResult.data;
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const updateResult = await this.deps.updater.update({
companyId,
id,
props: patchProps,
transaction,
});
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId,
customer: updateResult.data,
transaction,
});
if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
return Result.ok(this.deps.fullSnapshotBuilder.toOutput(readModelResult.data));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -1 +0,0 @@
export * from "./update-customer.use-case";

View File

@ -1,73 +0,0 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
import type { CustomerPatchProps } from "../../../domain";
import type { IUpdateCustomerInputMapper } from "../../mappers";
import type { ICustomerUpdater } from "../../services";
import type { ICustomerFullSnapshotBuilder } from "../../snapshot-builders";
type UpdateCustomerUseCaseInput = {
companyId: UniqueID;
customer_id: string;
dto: UpdateCustomerByIdRequestDTO;
};
type UpdateCustomerUseCaseDeps = {
dtoMapper: IUpdateCustomerInputMapper;
updater: ICustomerUpdater;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
};
export class UpdateCustomerUseCase {
private readonly dtoMapper: IUpdateCustomerInputMapper;
private readonly updater: ICustomerUpdater;
private readonly fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
private readonly transactionManager: ITransactionManager;
constructor(deps: UpdateCustomerUseCaseDeps) {
this.dtoMapper = deps.dtoMapper;
this.updater = deps.updater;
this.fullSnapshotBuilder = deps.fullSnapshotBuilder;
this.transactionManager = deps.transactionManager;
}
public execute(params: UpdateCustomerUseCaseInput) {
const { companyId, customer_id, dto } = params;
const idOrError = UniqueID.create(customer_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const id = idOrError.data;
// Mapear DTO → props de dominio
const patchPropsResult = this.dtoMapper.map(dto, { companyId });
if (patchPropsResult.isFailure) {
return patchPropsResult;
}
const patchProps: CustomerPatchProps = patchPropsResult.data;
return this.transactionManager.complete(async (transaction: unknown) => {
try {
const updateResult = await this.updater.update({
companyId,
id,
props: patchProps,
transaction,
});
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
return Result.ok(this.fullSnapshotBuilder.toOutput(updateResult.data));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -10,7 +10,7 @@ export * from "./infrastructure/persistence/sequelize";
export const customersAPIModule: IModuleServer = { export const customersAPIModule: IModuleServer = {
name: "customers", name: "customers",
version: "1.0.0", version: "1.0.0",
dependencies: [], dependencies: ["catalogs"],
/** /**
* Fase de SETUP * Fase de SETUP

View File

@ -0,0 +1,62 @@
import type { CatalogsPublicServicesType } from "@erp/catalogs/api";
import type { ModuleParams } from "@erp/core/api";
type CustomerCatalogsDeps = {
taxDefinition: {
finder: CatalogsPublicServicesType["taxDefinitions"]["finder"];
};
taxRegime: {
finder: CatalogsPublicServicesType["taxRegimes"]["finder"];
};
paymentMethod: {
finder: CatalogsPublicServicesType["paymentMethods"]["finder"];
};
paymentTerm: {
finder: CatalogsPublicServicesType["paymentTerms"]["finder"];
};
};
export function resolveCustomerCatalogsDeps(params: ModuleParams): CustomerCatalogsDeps {
const taxDefinition =
params.getService<CatalogsPublicServicesType["taxDefinitions"]>("catalogs:taxDefinitions");
if (!taxDefinition?.finder) {
throw new Error("Missing public service: catalogs:taxDefinitions.finder");
}
const taxRegime =
params.getService<CatalogsPublicServicesType["taxRegimes"]>("catalogs:taxRegimes");
if (!taxRegime?.finder) {
throw new Error("Missing public service: catalogs:taxRegimes.finder");
}
const paymentMethod =
params.getService<CatalogsPublicServicesType["paymentMethods"]>("catalogs:paymentMethods");
if (!paymentMethod?.finder) {
throw new Error("Missing public service: catalogs:paymentMethods.finder");
}
const paymentTerm =
params.getService<CatalogsPublicServicesType["paymentTerms"]>("catalogs:paymentTerms");
if (!paymentTerm?.finder) {
throw new Error("Missing public service: catalogs:paymentTerms.finder");
}
return {
taxDefinition: {
finder: taxDefinition.finder,
},
taxRegime: {
finder: taxRegime.finder,
},
paymentMethod: {
finder: paymentMethod.finder,
},
paymentTerm: {
finder: paymentTerm.finder,
},
};
}

View File

@ -1,5 +1,3 @@
import type { ICatalogs } from "@erp/core/api";
import { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../persistence"; import { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../persistence";
export interface ICustomerPersistenceMappers { export interface ICustomerPersistenceMappers {
@ -9,13 +7,9 @@ export interface ICustomerPersistenceMappers {
//createMapper: CreateCustomerInputMapper; //createMapper: CreateCustomerInputMapper;
} }
export const buildCustomerPersistenceMappers = ( export const buildCustomerPersistenceMappers = (): ICustomerPersistenceMappers => {
catalogs: ICatalogs
): ICustomerPersistenceMappers => {
const { taxCatalog } = catalogs;
// Mappers para el repositorio // Mappers para el repositorio
const domainMapper = new SequelizeCustomerDomainMapper({ taxCatalog }); const domainMapper = new SequelizeCustomerDomainMapper();
const summaryMapper = new SequelizeCustomerSummaryMapper(); const summaryMapper = new SequelizeCustomerSummaryMapper();
// Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado // Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado

View File

@ -1,4 +1,4 @@
import { type SetupParams, buildCatalogs } from "@erp/core/api"; import type { SetupParams } from "@erp/core/api";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd"; import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
@ -10,6 +10,7 @@ import {
} from "../../application"; } from "../../application";
import type { ICustomerCreateProps } from "../../domain"; import type { ICustomerCreateProps } from "../../domain";
import { resolveCustomerCatalogsDeps } from "./customer-catalog-deps.di";
import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di"; import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di";
import { buildCustomerRepository } from "./customer-repositories.di"; import { buildCustomerRepository } from "./customer-repositories.di";
import type { CustomersInternalDeps } from "./customers.di"; import type { CustomersInternalDeps } from "./customers.di";
@ -19,10 +20,10 @@ export function buildCustomerPublicServices(
deps: CustomersInternalDeps deps: CustomersInternalDeps
): ICustomerPublicServices { ): ICustomerPublicServices {
const { database } = params; const { database } = params;
const catalogs = buildCatalogs(); const catalogs = resolveCustomerCatalogsDeps(params);
// Infrastructure // Infrastructure
const persistenceMappers = buildCustomerPersistenceMappers(catalogs); const persistenceMappers = buildCustomerPersistenceMappers();
const repository = buildCustomerRepository({ database, mappers: persistenceMappers }); const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
const finder = buildCustomerFinder({ repository }); const finder = buildCustomerFinder({ repository });

View File

@ -1,4 +1,4 @@
import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api"; import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import { import {
type CreateCustomerUseCase, type CreateCustomerUseCase,
@ -15,7 +15,10 @@ import {
buildListCustomersUseCase, buildListCustomersUseCase,
buildUpdateCustomerUseCase, buildUpdateCustomerUseCase,
} from "../../application"; } from "../../application";
import { buildCustomerReadModelAssemblers } from "../../application/di";
import { buildCustomerCatalogResolvers } from "../../application/di/customer-catalog-resolvers.di";
import { resolveCustomerCatalogsDeps } from "./customer-catalog-deps.di";
import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di"; import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di";
import { buildCustomerRepository } from "./customer-repositories.di"; import { buildCustomerRepository } from "./customer-repositories.di";
@ -39,21 +42,33 @@ export type CustomersInternalDeps = {
export function buildCustomersDependencies(params: ModuleParams): CustomersInternalDeps { export function buildCustomersDependencies(params: ModuleParams): CustomersInternalDeps {
const { database } = params; const { database } = params;
// Infrastructure const catalogs = resolveCustomerCatalogsDeps(params);
const transactionManager = buildTransactionManager(database); const transactionManager = buildTransactionManager(database);
const catalogs = buildCatalogs(); const persistenceMappers = buildCustomerPersistenceMappers();
const persistenceMappers = buildCustomerPersistenceMappers(catalogs);
const repository = buildCustomerRepository({ database, mappers: persistenceMappers }); const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
//const numberService = buildCustomerNumberGenerator();
// Application helpers const inputMappers = buildCustomerInputMappers();
const inputMappers = buildCustomerInputMappers(catalogs); const snapshotBuilders = buildCustomerSnapshotBuilders();
const catalogResolvers = buildCustomerCatalogResolvers({
taxDefinitionFinder: catalogs.taxDefinition.finder,
taxRegimeFinder: catalogs.taxRegime.finder,
paymentMethodFinder: catalogs.paymentMethod.finder,
paymentTermFinder: catalogs.paymentTerm.finder,
});
const readModelAssemblers = buildCustomerReadModelAssemblers({
paymentMethodFinder: catalogs.paymentMethod.finder,
taxRegimeFinder: catalogs.taxRegime.finder,
paymentTermFinder: catalogs.paymentTerm.finder,
});
const finder = buildCustomerFinder({ repository }); const finder = buildCustomerFinder({ repository });
const creator = buildCustomerCreator({ repository }); const creator = buildCustomerCreator({ repository });
const updater = buildCustomerUpdater({ repository }); const updater = buildCustomerUpdater({ repository });
const snapshotBuilders = buildCustomerSnapshotBuilders();
//const documentGeneratorPipeline = buildCustomerDocumentService(params); //const documentGeneratorPipeline = buildCustomerDocumentService(params);
// Internal use cases (factories) // Internal use cases (factories)
@ -69,6 +84,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
getCustomerById: () => getCustomerById: () =>
buildGetCustomerByIdUseCase({ buildGetCustomerByIdUseCase({
finder, finder,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full, fullSnapshotBuilder: snapshotBuilders.full,
transactionManager, transactionManager,
}), }),
@ -77,6 +93,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
buildCreateCustomerUseCase({ buildCreateCustomerUseCase({
creator, creator,
dtoMapper: inputMappers.createInputMapper, dtoMapper: inputMappers.createInputMapper,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full, fullSnapshotBuilder: snapshotBuilders.full,
transactionManager, transactionManager,
}), }),
@ -85,6 +102,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
buildUpdateCustomerUseCase({ buildUpdateCustomerUseCase({
updater, updater,
dtoMapper: inputMappers.updateInputMapper, dtoMapper: inputMappers.updateInputMapper,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full, fullSnapshotBuilder: snapshotBuilders.full,
transactionManager, transactionManager,
}), }),

View File

@ -1,4 +1,3 @@
import type { TaxCatalogProvider } from "@erp/core";
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import { import {
City, City,
@ -32,13 +31,6 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
CustomerCreationAttributes, CustomerCreationAttributes,
Customer Customer
> { > {
private readonly taxCatalog: TaxCatalogProvider;
constructor(params: { taxCatalog: TaxCatalogProvider }) {
super();
this.taxCatalog = params.taxCatalog;
}
public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result<Customer, Error> { public mapToDomain(source: CustomerModel, params?: MapperParamsType): Result<Customer, Error> {
try { try {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];

View File

@ -67,11 +67,13 @@ export class CustomerRepository
const dtoResult = this.domainMapper.mapToPersistence(customer); const dtoResult = this.domainMapper.mapToPersistence(customer);
const { id, ...updatePayload } = dtoResult.data; const { id, ...updatePayload } = dtoResult.data;
console.log(dtoResult.data);
const [affected] = await CustomerModel.update(updatePayload, { const [affected] = await CustomerModel.update(updatePayload, {
where: { id /*, version */ }, where: { id /*, version */ },
//fields: Object.keys(updatePayload),
transaction, transaction,
individualHooks: true, //individualHooks: true,
}); });
if (affected === 0) { if (affected === 0) {

View File

@ -4,11 +4,10 @@ export const CreateCustomerRequestSchema = z.object({
id: z.string().nonempty(), id: z.string().nonempty(),
reference: z.string().optional(), reference: z.string().optional(),
is_company: z.string().toLowerCase().default("true"), is_company: z.boolean().default(true),
name: z.string(), name: z.string(),
trade_name: z.string().optional(), trade_name: z.string().optional(),
tin: z.string().optional(), tin: z.string().optional(),
default_taxes: z.string().default(""),
street: z.string().optional(), street: z.string().optional(),
street2: z.string().optional(), street2: z.string().optional(),
@ -32,8 +31,8 @@ export const CreateCustomerRequestSchema = z.object({
payment_method_id: z.uuid().nullable().optional(), payment_method_id: z.uuid().nullable().optional(),
payment_term_id: z.uuid().nullable().optional(), payment_term_id: z.uuid().nullable().optional(),
tax_regime_code: z.string().nullable().optional(), tax_regime_code: z.string().nullable().optional(),
uses_equivalence_surcharge: z.boolean().optional(), uses_equivalence_surcharge: z.boolean().default(false),
uses_retention: z.boolean().optional(), uses_retention: z.boolean().default(false),
language_code: z.string().toLowerCase().default("es"), language_code: z.string().toLowerCase().default("es"),
currency_code: z.string().toUpperCase().default("EUR"), currency_code: z.string().toUpperCase().default("EUR"),

View File

@ -1,9 +1,12 @@
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useCustomerCreateController } from "./use-customer-create.controller"; import { useCustomerCreateController } from "./use-customer-create.controller";
export const useCustomerCreatePageController = () => { export const useCustomerCreatePageController = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo") ?? "/customers";
const createCtrl = useCustomerCreateController({ const createCtrl = useCustomerCreateController({
onCreated: (customer) => navigate(`/customers/${customer.id}`), onCreated: (customer) => navigate(`/customers/${customer.id}`),
@ -11,5 +14,6 @@ export const useCustomerCreatePageController = () => {
return { return {
createCtrl, createCtrl,
returnTo,
}; };
}; };

View File

@ -6,6 +6,7 @@ import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import type { CreateCustomerParams, Customer } from "../../shared"; import type { CreateCustomerParams, Customer } from "../../shared";
import { useCustomerCreateMutation } from "../../shared/hooks/use-customer-create-mutation"; import { useCustomerCreateMutation } from "../../shared/hooks/use-customer-create-mutation";
import { mapCustomerToCustomerCreateForm } from "../adapters";
import { import {
type CustomerCreateForm, type CustomerCreateForm,
CustomerCreateFormSchema, CustomerCreateFormSchema,
@ -47,13 +48,19 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
}; };
const submitHandler = form.handleSubmit( const submitHandler = form.handleSubmit(
async (formData) => { async (formData: CustomerCreateForm) => {
const params: CreateCustomerParams = buildCreateCustomerParams(formData); const params: CreateCustomerParams = buildCreateCustomerParams(formData);
try { try {
// Enviamos cambios al servidor // Enviamos cambios al servidor
const created = await mutateAsync(params); // payload es CustomerCreatePayload const created = await mutateAsync(params); // payload es CustomerCreatePayload
// Ha ido bien -> actualizamos form con datos reales
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
form.reset(mapCustomerToCustomerCreateForm(created), {
keepDirty: false,
});
if (options?.successToasts !== false) { if (options?.successToasts !== false) {
showSuccessToast( showSuccessToast(
t("create.feedback.success.title"), t("create.feedback.success.title"),

View File

@ -19,7 +19,6 @@ export interface CustomerCreateForm {
name: string; name: string;
tradeName: string; tradeName: string;
tin: string; tin: string;
defaultTaxes: string[];
street: string; street: string;
street2: string; street2: string;

View File

@ -1,3 +1,13 @@
import {
CountryCodeSchema,
CurrencyCodeSchema,
LandPhoneSchema,
LanguageCodeSchema,
MobilePhoneSchema,
PostalCodeSchema,
TinSchema,
WebsiteSchema,
} from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
/** /**
@ -17,32 +27,31 @@ import { z } from "zod/v4";
export const CustomerCreateFormSchema = z.object({ export const CustomerCreateFormSchema = z.object({
reference: z.string(), reference: z.string(),
isCompany: z.boolean(), isCompany: z.boolean(),
name: z.string().min(1, "El nombre es obligatorio"), name: z.string().min(1, "El nombre es obligatorio"),
tradeName: z.string(), tradeName: z.string(),
tin: z.string(), tin: TinSchema.or(z.literal("")),
defaultTaxes: z.array(z.string()),
street: z.string(), street: z.string(),
street2: z.string(), street2: z.string(),
city: z.string(), city: z.string(),
province: z.string(), province: z.string(),
postalCode: z.string(), postalCode: PostalCodeSchema.or(z.literal("")),
country: z.string().min(1, "El país es obligatorio"), country: CountryCodeSchema.or(z.literal("")),
primaryEmail: z.email("Email inválido"), primaryEmail: z.email("Email inválido").or(z.literal("")),
secondaryEmail: z.email("Email inválido"), secondaryEmail: z.email("Email inválido").or(z.literal("")),
primaryPhone: z.string(), primaryPhone: LandPhoneSchema.or(z.literal("")),
secondaryPhone: z.string(), secondaryPhone: LandPhoneSchema.or(z.literal("")),
primaryMobile: z.string(), primaryMobile: MobilePhoneSchema.or(z.literal("")),
secondaryMobile: z.string(), secondaryMobile: MobilePhoneSchema.or(z.literal("")),
fax: z.string(), fax: LandPhoneSchema.or(z.literal("")),
website: z.url("URL inválida"), website: WebsiteSchema.or(z.literal("")),
legalRecord: z.string(), legalRecord: z.string().or(z.literal("")),
languageCode: z.string().min(1, "El idioma es obligatorio"), languageCode: LanguageCodeSchema,
currencyCode: z.string().min(1, "La moneda es obligatoria"), currencyCode: CurrencyCodeSchema,
}); });

View File

@ -0,0 +1,122 @@
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components";
import {
CancelActionButton,
FormActionsBar,
type FormSecondaryAction,
FormSecondaryActionsMenu,
RhfSubmitActionButton,
} from "@repo/rdx-ui/components";
import type { ReactNode } from "react";
export interface CustomerCreateHeaderLabels {
title: string;
back: string;
modified: string;
cancel: string;
save: string;
saving: string;
moreActions: string;
keyboardShortcuts: string;
delete: string;
}
export interface CustomerCreateHeaderProps {
formId?: string;
labels: CustomerCreateHeaderLabels;
onCancel?: () => void;
onDuplicate?: () => void;
onDelete?: () => void;
disabled?: boolean;
readOnly?: boolean;
isSaving?: boolean;
hasChanges?: boolean;
className?: string;
children?: ReactNode;
}
export const CustomerCreateHeader = ({
formId,
labels,
onCancel,
onDuplicate,
onDelete,
disabled = false,
readOnly = false,
isSaving = false,
hasChanges = false,
className,
children,
}: CustomerCreateHeaderProps) => {
const computedDisabled = disabled || isSaving;
const secondaryActions: FormSecondaryAction[] = [
/*{
id: "duplicate",
label: labels.duplicate,
icon: <CopyIcon aria-hidden="true" className="mr-2 size-4" />,
hidden: !onDuplicate,
disabled: computedDisabled,
onSelect: () => onDuplicate?.(),
},
{
id: "delete",
label: labels.delete,
icon: <Trash2Icon aria-hidden="true" className="mr-2 size-4" />,
hidden: !onDelete || readOnly,
disabled: computedDisabled,
destructive: true,
onSelect: () => onDelete?.(),
},*/
];
return (
<PageFormHeader
actions={
<FormActionsBar align="end" reverseOnMobile={false}>
<PageKeyboardShortcutsButton
className="hidden sm:flex"
label={labels.keyboardShortcuts}
shortcuts={[
{ keys: "Ctrl+S", label: labels.save },
{ keys: "Esc", label: labels.cancel },
]}
/>
<CancelActionButton
className="hidden sm:flex"
disabled={computedDisabled}
label={labels.cancel}
onCancel={() => onCancel?.()}
variant="outline"
/>
<RhfSubmitActionButton
busyLabel={labels.saving}
disabled={computedDisabled || readOnly}
formId={formId}
isBusy={isSaving}
label={labels.save}
/>
<FormSecondaryActionsMenu
actions={secondaryActions}
disabled={computedDisabled}
label={labels.moreActions}
/>
</FormActionsBar>
}
backLabel={labels.back}
className={className}
onBack={onCancel}
showStatus={hasChanges}
statusLabel={labels.modified}
title={labels.title}
>
{children}
</PageFormHeader>
);
};

View File

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

View File

@ -15,15 +15,13 @@ import { useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import type { CustomerUpdateForm } from "../../entities"; import type { CustomerCreateForm } from "../../entities";
import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select";
interface CustomerBasicInfoFieldsProps extends React.ComponentProps<typeof FieldSet> {} interface CustomerBasicInfoFieldsProps extends React.ComponentProps<typeof FieldSet> {}
export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicInfoFieldsProps) => { export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicInfoFieldsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { control, setFocus } = useFormContext<CustomerUpdateForm>(); const { control, setFocus } = useFormContext<CustomerCreateForm>();
useEffect(() => { useEffect(() => {
setFocus("name"); setFocus("name");
@ -60,17 +58,17 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
disabled={field.disabled} disabled={field.disabled}
name={field.name} name={field.name}
onValueChange={(value) => { onValueChange={(value) => {
field.onChange(value === "true"); field.onChange(value === "company");
}} }}
required required
value={field.value ? "true" : "false"} value={field.value ? "company" : "individual"}
> >
<Field data-invalid={fieldState.invalid} orientation="horizontal"> <Field data-invalid={fieldState.invalid} orientation="horizontal">
<FieldContent> <FieldContent>
<RadioGroupItem <RadioGroupItem
aria-invalid={fieldState.invalid} aria-invalid={fieldState.invalid}
id="customer-type-company" id="customer-type-company"
value="true" value="company"
/> />
<FieldLabel <FieldLabel
className="cursor-pointer text-sm font-medium leading-none" className="cursor-pointer text-sm font-medium leading-none"
@ -86,7 +84,7 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
<RadioGroupItem <RadioGroupItem
aria-invalid={fieldState.invalid} aria-invalid={fieldState.invalid}
id="customer-type-individual" id="customer-type-individual"
value="false" value="individual"
/> />
<FieldLabel <FieldLabel
className="cursor-pointer text-sm font-medium leading-none" className="cursor-pointer text-sm font-medium leading-none"
@ -111,7 +109,6 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
label={t("fields.tin.label")} label={t("fields.tin.label")}
name="tin" name="tin"
placeholder={t("fields.tin.placeholder")} placeholder={t("fields.tin.placeholder")}
required
/> />
<TextField <TextField
@ -130,32 +127,6 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
placeholder={t("fields.reference.placeholder")} placeholder={t("fields.reference.placeholder")}
/> />
<Field className="lg:col-span-2">
<Controller
control={control}
name="defaultTaxes"
render={({ field, fieldState }) => (
<Field className="gap-1" data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="defaultTaxes">
{t("fields.default_taxes.label")}
</FieldLabel>
<CustomerTaxesMultiSelect
description={t("fields.default_taxes.description")}
label={t("fields.default_taxes.label")}
onChange={field.onChange}
placeholder={t("fields.default_taxes.placeholder")}
required
value={field.value}
/>
<FieldDescription>{t("fields.default_taxes.description")}</FieldDescription>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</Field>
<TextAreaField <TextAreaField
className="lg:col-span-full" className="lg:col-span-full"
description={t("fields.legal_record.description")} description={t("fields.legal_record.description")}

View File

@ -37,7 +37,6 @@ export const CustomerContactFields = ({ className, ...props }: CustomerContactFi
} }
name="primaryEmail" name="primaryEmail"
placeholder={t("fields.email_primary.placeholder")} placeholder={t("fields.email_primary.placeholder")}
required
typePreset="email" typePreset="email"
/> />

View File

@ -1,61 +1,64 @@
import { ErrorAlert, PageHeader } from "@erp/core/components"; import { ErrorAlert } from "@erp/core/components";
import { UnsavedChangesProvider } from "@erp/core/hooks"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { useCustomerCreatePageController } from "../../controllers"; import { useCustomerCreatePageController } from "../../controllers";
import { CustomerCreateHeader } from "../blocks";
import { CustomerCreateEditorForm } from "../editor"; import { CustomerCreateEditorForm } from "../editor";
export const CustomerCreatePage = () => { export const CustomerCreatePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { createCtrl } = useCustomerCreatePageController(); const { createCtrl, returnTo } = useCustomerCreatePageController();
const { form, formId, onSubmit, resetForm, isCreating, isCreateError, createError } = createCtrl; const { form, formId, onSubmit, resetForm, isCreating, isCreateError, createError } = createCtrl;
const { navigateBack } = useReturnToNavigation({
fallbackPath: returnTo,
});
const handleCancel = () => navigateBack();
return ( return (
<div className="fixed inset-0 flex flex-col overflow-hidden"> <div className="fixed inset-0 flex flex-col overflow-hidden">
<FormProvider {...form}> <FormProvider {...createCtrl.form}>
<UnsavedChangesProvider isDirty={form.formState.isDirty}> <UnsavedChangesProvider isDirty={createCtrl.form.formState.isDirty}>
<AppHeader className="mx-auto max-w-7xl space-y-4"> <CustomerCreateHeader
<PageHeader formId={createCtrl.formId}
description={t("create.description")} hasChanges={createCtrl.form.formState.isDirty}
onBackClick={() => navigate("/customers/list")} isSaving={createCtrl.form.formState.isSubmitting}
rightSlot={ labels={{
/* title: "Nuevo cliente",
<FormCommitButtonGroup back: "Volver",
cancel={{ modified: "",
to: "/customers/list", cancel: "Cancelar",
}} save: "Guardar",
disabled={createCtrl.isCreating} saving: "Guardando...",
isLoading={createCtrl.isCreating} moreActions: "Más acciones",
onReset={createCtrl.form.formState.isDirty ? createCtrl.resetForm : undefined} keyboardShortcuts: "Ver atajos de teclado",
submit={{ delete: "Eliminar",
formId: createCtrl.formId,
}} }}
onCancel={handleCancel}
//onDelete={openDeleteDialog}
//onDuplicate={handleDuplicate}
//onExportPdf={handleExportPdf}
/> />
*/ <></>
}
title={t("create.title")}
/>
</AppHeader>
<AppContent className="mx-auto max-w-7xl space-y-4">
{isCreateError && ( {isCreateError && (
<ErrorAlert <ErrorAlert
message={(createError as Error)?.message ?? t("create.errors.message")} message={(createError as Error)?.message ?? t("create.errors.message")}
title={t("create.errors.title")} title={t("create.errors.title")}
/> />
)} )}
<div className="flex-1 overflow-y-auto">
<CustomerCreateEditorForm <CustomerCreateEditorForm
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6" className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto my-6"
formId={formId} formId={formId}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
</AppContent> </div>
</UnsavedChangesProvider> </UnsavedChangesProvider>
</FormProvider> </FormProvider>
</div> </div>

View File

@ -30,11 +30,10 @@ export const buildCreateCustomerParams = (formData: CustomerCreateForm): CreateC
data: { data: {
id, id,
reference: formData.reference, reference: formData.reference,
is_company: formData.isCompany ? "1" : "0", is_company: formData.isCompany,
name: formData.name, name: formData.name,
trade_name: formData.tradeName, trade_name: formData.tradeName,
tin: formData.tin, tin: formData.tin,
default_taxes: formData.defaultTaxes.join(";"),
street: formData.street, street: formData.street,
street2: formData.street2, street2: formData.street2,
@ -55,6 +54,9 @@ export const buildCreateCustomerParams = (formData: CustomerCreateForm): CreateC
legal_record: formData.legalRecord, legal_record: formData.legalRecord,
uses_equivalence_surcharge: false,
uses_retention: false,
language_code: formData.languageCode, language_code: formData.languageCode,
currency_code: formData.currencyCode, currency_code: formData.currencyCode,
}, },

View File

@ -51,7 +51,11 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
layout: "app-fullscreen", layout: "app-fullscreen",
protected: true, protected: true,
}, },
element: <CustomerCreatePage />, element: (
<CustomerLayout>
<CustomerCreatePage />
</CustomerLayout>
),
}, },
{ {
@ -60,7 +64,11 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
layout: "app-fullscreen", layout: "app-fullscreen",
protected: true, protected: true,
}, },
element: <CustomerUpdatePage />, element: (
<CustomerLayout>
<CustomerUpdatePage />
</CustomerLayout>
),
}, },
{ {
@ -69,7 +77,11 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
layout: "app-fullscreen", layout: "app-fullscreen",
protected: true, protected: true,
}, },
element: <CustomerViewPage />, element: (
<CustomerLayout>
<CustomerViewPage />
</CustomerLayout>
),
}, },
/* /*

View File

@ -8,13 +8,12 @@ const MODULE_VERSION = "1.0.0";
export const CustomersModuleManifest: IModuleClient = { export const CustomersModuleManifest: IModuleClient = {
name: MODULE_NAME, name: MODULE_NAME,
version: MODULE_VERSION, version: MODULE_VERSION,
dependencies: ["auth", "Core"], dependencies: ["auth", "Core", "Catalogs"],
protected: true,
layout: "app",
routes: (params: ModuleClientParams) => { protected: true, // protegido por defecto
return CustomerRoutes(params); layout: "app-sidebar", // layout por defecto
},
routes: (params: ModuleClientParams) => CustomerRoutes(params),
}; };
export default CustomersModuleManifest; export default CustomersModuleManifest;

View File

@ -12,11 +12,6 @@ import type { Customer } from "../entities";
export const GetCustomerByIdAdapter = { export const GetCustomerByIdAdapter = {
fromDTO(dto: GetCustomerByIdResult, context?: unknown): Customer { fromDTO(dto: GetCustomerByIdResult, context?: unknown): Customer {
const taxesAdapter = (taxes: string | null) =>
taxes?.split(";").filter((item) => item !== "#" && item.trim() !== "") || null;
const defaultTaxes = taxesAdapter(dto.default_taxes);
return { return {
id: dto.id, id: dto.id,
companyId: dto.company_id, companyId: dto.company_id,
@ -49,7 +44,14 @@ export const GetCustomerByIdAdapter = {
legalRecord: dto.legal_record, legalRecord: dto.legal_record,
defaultTaxes: defaultTaxes, paymentMethod: dto.payment_method,
paymentTerm: dto.payment_term,
taxRegime: dto.tax_regime,
fiscalDefaults: {
usesEquivalenceSurcharge: dto.fiscal_defaults.uses_equivalence_surcharge,
usesRetention: dto.fiscal_defaults.uses_retention,
},
status: dto.status, status: dto.status,
languageCode: dto.language_code, languageCode: dto.language_code,
currencyCode: dto.currency_code, currencyCode: dto.currency_code,

View File

@ -38,7 +38,26 @@ export interface Customer {
legalRecord: string | null; legalRecord: string | null;
defaultTaxes: string[] | null; paymentMethod: {
id: string;
name: string;
description: string;
} | null;
paymentTerm: {
id: string;
description: string;
} | null;
taxRegime: {
code: string;
description: string;
} | null;
fiscalDefaults: {
usesEquivalenceSurcharge: boolean;
usesRetention: boolean;
};
languageCode: string; languageCode: string;
currencyCode: string; currencyCode: string;

View File

@ -13,3 +13,17 @@ export function toValidationErrors(error: ZodError<unknown>) {
message: err.message, message: err.message,
})); }));
} }
/**
*
ZodError: [
{
"expected": "boolean",
"code": "invalid_type",
"path": [
"is_company"
],
"message": "Invalid input: expected boolean, received string"
}
]
*/

View File

@ -1,5 +1,9 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
export const CustomerLayout = ({ children }: PropsWithChildren) => { export const CustomerLayout = ({ children }: PropsWithChildren) => {
return <div className="flex flex-col h-full w-full">{children}</div>; return (
<div className="flex flex-col h-full w-full" id="customers">
{children}
</div>
);
}; };

View File

@ -13,15 +13,16 @@ import type { CustomerUpdateForm } from "../entities"; /**
*/ */
import { buildCustomerUpdateDefault } from "../utils"; import { buildCustomerUpdateDefault } from "../utils";
export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpdateForm => ({ export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpdateForm => {
console.log(customer);
return {
reference: customer.reference ?? "", reference: customer.reference ?? "",
isCompany: customer.isCompany ?? buildCustomerUpdateDefault().isCompany, isCompany: customer.isCompany ?? buildCustomerUpdateDefault().isCompany,
name: customer.name, name: customer.name,
tradeName: customer.tradeName ?? "", tradeName: customer.tradeName ?? "",
tin: customer.tin ?? "", tin: customer.tin ?? "",
defaultTaxes: customer.defaultTaxes ?? [],
street: customer.address.street ?? "", street: customer.address.street ?? "",
street2: customer.address.street2 ?? "", street2: customer.address.street2 ?? "",
city: customer.address.city ?? "", city: customer.address.city ?? "",
@ -41,6 +42,17 @@ export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpd
legalRecord: customer.legalRecord ?? "", legalRecord: customer.legalRecord ?? "",
paymentMethodId: customer.paymentMethod?.id ?? "",
paymentTermId: customer.paymentTerm?.id ?? "",
taxRegimeCode: customer.taxRegime?.code ?? "",
usesEquivalenceSurcharge:
customer.fiscalDefaults.usesEquivalenceSurcharge ??
buildCustomerUpdateDefault().usesEquivalenceSurcharge,
usesRetention:
customer.fiscalDefaults.usesRetention ?? buildCustomerUpdateDefault().usesRetention,
languageCode: customer.languageCode ?? buildCustomerUpdateDefault().languageCode, languageCode: customer.languageCode ?? buildCustomerUpdateDefault().languageCode,
currencyCode: customer.currencyCode ?? buildCustomerUpdateDefault().currencyCode, currencyCode: customer.currencyCode ?? buildCustomerUpdateDefault().currencyCode,
}); };
};

View File

@ -1,2 +1,3 @@
export * from "./use-customer-update.controller"; export * from "./use-customer-update.controller";
export * from "./use-customer-update-fiscal-controller";
export * from "./use-customer-update-page.controller"; export * from "./use-customer-update-page.controller";

View File

@ -0,0 +1,79 @@
import {
getPaymentMethodOptions,
usePaymentMethodsListQuery,
} from "@erp/catalogs/client/payment-methods";
import {
getPaymentTermOptions,
usePaymentTermsListQuery,
} from "@erp/catalogs/client/payment-terms";
import { getTaxRegimeOptions, useTaxRegimesListQuery } from "@erp/catalogs/client/tax-regimes";
import { useMemo } from "react";
export const useCustomerUpdateFiscalController = () => {
const paymentMethodsQuery = usePaymentMethodsListQuery({
criteria: {
pageSize: 999,
filters: [
{
field: "isActive",
operator: "EQUALS",
value: "true",
},
],
},
});
const paymentTermsQuery = usePaymentTermsListQuery({
criteria: {
filters: [
{
field: "isActive",
operator: "EQUALS",
value: "true",
},
],
},
});
const taxRegimesQuery = useTaxRegimesListQuery({
criteria: {
filters: [
{
field: "isActive",
operator: "EQUALS",
value: "true",
},
],
},
});
const paymentMethodOptions = useMemo(() => {
return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []);
}, [paymentMethodsQuery.data?.items]);
const paymentTermOptions = useMemo(() => {
return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []);
}, [paymentTermsQuery.data?.items]);
const taxRegimeOptions = useMemo(() => {
return getTaxRegimeOptions(taxRegimesQuery.data?.items ?? []);
}, [taxRegimesQuery.data?.items]);
return {
paymentMethodOptions,
paymentTermOptions,
taxRegimeOptions,
isLoading: paymentMethodsQuery.isLoading || paymentTermsQuery.isLoading,
isFetching: paymentMethodsQuery.isFetching || paymentTermsQuery.isFetching,
isError: paymentMethodsQuery.isError || paymentTermsQuery.isError,
error: paymentMethodsQuery.error ?? paymentTermsQuery.error,
};
};
export type UseCustomerUpdateFiscalControllerResult = ReturnType<
typeof useCustomerUpdateFiscalController
>;

View File

@ -26,6 +26,8 @@ import {
buildUpdateCustomerByIdParams, buildUpdateCustomerByIdParams,
} from "../utils"; } from "../utils";
import { useCustomerUpdateFiscalController } from "./use-customer-update-fiscal-controller";
export interface UseCustomerUpdateControllerOptions { export interface UseCustomerUpdateControllerOptions {
onUpdated?(updated: Customer): void; onUpdated?(updated: Customer): void;
successToasts?: boolean; // mostrar o no toast automáticamente successToasts?: boolean; // mostrar o no toast automáticamente
@ -155,6 +157,7 @@ export const useCustomerUpdateController = (
options?.onUpdated?.(updated); options?.onUpdated?.(updated);
} catch (error: unknown) { } catch (error: unknown) {
const normalizedError = normalizeSubmitError(error); const normalizedError = normalizeSubmitError(error);
console.error(normalizedError);
// No revierto el form para que no se pierdan los cambios que // No revierto el form para que no se pierdan los cambios que
// ha hecho el usuario y no han sido guardados. // ha hecho el usuario y no han sido guardados.
@ -192,6 +195,7 @@ export const useCustomerUpdateController = (
} }
}, },
(errors: FieldErrors<CustomerUpdateForm>) => { (errors: FieldErrors<CustomerUpdateForm>) => {
console.error(errors);
focusFirstInputFormError(form); focusFirstInputFormError(form);
showWarningToast( showWarningToast(
@ -207,6 +211,8 @@ export const useCustomerUpdateController = (
submitHandler(event); submitHandler(event);
}; };
const fiscalCtrl = useCustomerUpdateFiscalController();
return { return {
// form // form
form, form,
@ -227,6 +233,9 @@ export const useCustomerUpdateController = (
isUpdateError, isUpdateError,
updateError, updateError,
// Otros controladores
fiscalCtrl,
// No devolver FormProvider, así el controller es más // No devolver FormProvider, así el controller es más
// flexible y reusable (p.ej. para un modal) // flexible y reusable (p.ej. para un modal)
// FormProvider, // FormProvider,

View File

@ -20,8 +20,6 @@ export interface CustomerUpdateForm {
tradeName: string; tradeName: string;
tin: string; tin: string;
defaultTaxes: string[];
street: string; street: string;
street2: string; street2: string;
city: string; city: string;
@ -41,6 +39,13 @@ export interface CustomerUpdateForm {
legalRecord: string; legalRecord: string;
paymentMethodId: string;
paymentTermId: string;
taxRegimeCode: string;
usesEquivalenceSurcharge: boolean;
usesRetention: boolean;
languageCode: string; languageCode: string;
currencyCode: string; currencyCode: string;
} }

View File

@ -6,7 +6,7 @@ import {
MobilePhoneSchema, MobilePhoneSchema,
PostalCodeSchema, PostalCodeSchema,
TinSchema, TinSchema,
URLSchema, WebsiteSchema,
} from "@erp/core"; } from "@erp/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
@ -31,9 +31,7 @@ export const CustomerUpdateFormSchema = z.object({
name: z.string().min(1, "El nombre es obligatorio"), name: z.string().min(1, "El nombre es obligatorio"),
tradeName: z.string(), tradeName: z.string(),
tin: TinSchema, tin: TinSchema.or(z.literal("")),
defaultTaxes: z.array(z.string()),
street: z.string(), street: z.string(),
street2: z.string(), street2: z.string(),
@ -50,11 +48,18 @@ export const CustomerUpdateFormSchema = z.object({
primaryMobile: MobilePhoneSchema.or(z.literal("")), primaryMobile: MobilePhoneSchema.or(z.literal("")),
secondaryMobile: MobilePhoneSchema.or(z.literal("")), secondaryMobile: MobilePhoneSchema.or(z.literal("")),
fax: LandPhoneSchema.or(z.literal("")).or(z.literal("")), fax: LandPhoneSchema.or(z.literal("")),
website: URLSchema.or(z.literal("")).or(z.literal("")), website: WebsiteSchema.or(z.literal("")),
legalRecord: z.string().or(z.literal("")), legalRecord: z.string().or(z.literal("")),
paymentMethodId: z.string().or(z.literal("")),
paymentTermId: z.string().or(z.literal("")),
taxRegimeCode: z.string().or(z.literal("")),
usesEquivalenceSurcharge: z.boolean(),
usesRetention: z.boolean(),
languageCode: LanguageCodeSchema, languageCode: LanguageCodeSchema,
currencyCode: CurrencyCodeSchema, currencyCode: CurrencyCodeSchema,
}); });

View File

@ -16,8 +16,6 @@ export type CustomerUpdatePatch = {
tradeName?: string | null; tradeName?: string | null;
tin?: string | null; tin?: string | null;
defaultTaxes?: string[];
street?: string | null; street?: string | null;
street2?: string | null; street2?: string | null;
city?: string | null; city?: string | null;
@ -37,6 +35,13 @@ export type CustomerUpdatePatch = {
legalRecord?: string | null; legalRecord?: string | null;
paymentMethodId?: string | null;
paymentTermId?: string | null;
taxRegimeCode?: string | null;
usesEquivalenceSurcharge?: boolean;
usesRetention?: boolean;
languageCode?: string; languageCode?: string;
currencyCode?: string; currencyCode?: string;
}; };

View File

@ -1,4 +1,5 @@
import { FormSectionCard, FormSectionGrid, SelectField, TextField } from "@repo/rdx-ui/components"; import { FormSectionCard, FormSectionGrid, SelectField, TextField } from "@repo/rdx-ui/components";
import { MapPinIcon } from "lucide-react";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { COUNTRY_OPTIONS } from "../../../shared"; import { COUNTRY_OPTIONS } from "../../../shared";
@ -17,11 +18,11 @@ export const CustomerAddressEditor = ({
return ( return (
<FormSectionCard <FormSectionCard
description={t("form_groups.address.description")} description={t("form_groups.address.description")}
icon={<MapPinIcon className="size-5" />}
title={t("form_groups.address.title")} title={t("form_groups.address.title")}
> >
<FormSectionGrid> <FormSectionGrid className="md:grid-cols-2">
<TextField <TextField
className="md:col-span-6"
description={t("fields.street.description")} description={t("fields.street.description")}
disabled={disabled} disabled={disabled}
label={t("fields.street.label")} label={t("fields.street.label")}
@ -30,7 +31,6 @@ export const CustomerAddressEditor = ({
readOnly={readOnly} readOnly={readOnly}
/> />
<TextField <TextField
className="md:col-span-6"
description={t("fields.street2.description")} description={t("fields.street2.description")}
disabled={disabled} disabled={disabled}
label={t("fields.street2.label")} label={t("fields.street2.label")}
@ -40,7 +40,6 @@ export const CustomerAddressEditor = ({
/> />
<TextField <TextField
className="md:col-span-6"
description={t("fields.city.description")} description={t("fields.city.description")}
disabled={disabled} disabled={disabled}
label={t("fields.city.label")} label={t("fields.city.label")}
@ -50,17 +49,6 @@ export const CustomerAddressEditor = ({
/> />
<TextField <TextField
className="md:col-span-6"
description={t("fields.province.description")}
disabled={disabled}
label={t("fields.province.label")}
name="province"
placeholder={t("fields.province.placeholder")}
readOnly={readOnly}
/>
<TextField
className="md:col-span-2 md:col-start-1"
description={t("fields.postal_code.description")} description={t("fields.postal_code.description")}
disabled={disabled} disabled={disabled}
label={t("fields.postal_code.label")} label={t("fields.postal_code.label")}
@ -69,8 +57,16 @@ export const CustomerAddressEditor = ({
readOnly={readOnly} readOnly={readOnly}
/> />
<TextField
description={t("fields.province.description")}
disabled={disabled}
label={t("fields.province.label")}
name="province"
placeholder={t("fields.province.placeholder")}
readOnly={readOnly}
/>
<SelectField <SelectField
className="md:col-span-2"
description={t("fields.country.description")} description={t("fields.country.description")}
disabled={disabled} disabled={disabled}
items={[...COUNTRY_OPTIONS]} items={[...COUNTRY_OPTIONS]}

View File

@ -14,12 +14,11 @@ import {
RadioGroup, RadioGroup,
RadioGroupItem, RadioGroupItem,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { Building2Icon, FileTextIcon, UserIcon } from "lucide-react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select";
interface CustomerBasicInfoEditorProps { interface CustomerBasicInfoEditorProps {
disabled?: boolean; disabled?: boolean;
readOnly?: boolean; readOnly?: boolean;
@ -35,11 +34,11 @@ export const CustomerBasicInfoEditor = ({
return ( return (
<FormSectionCard <FormSectionCard
description={t("form_groups.basic_info.description")} description={t("form_groups.basic_info.description")}
icon={<FileTextIcon className="size-5" />}
title={t("form_groups.basic_info.title")} title={t("form_groups.basic_info.title")}
> >
<FormSectionGrid> <FormSectionGrid className="md:grid-cols-2">
<TextField <TextField
className="md:col-span-6"
description={t("fields.name.description")} description={t("fields.name.description")}
disabled={disabled} disabled={disabled}
label={t("fields.name.label")} label={t("fields.name.label")}
@ -49,35 +48,46 @@ export const CustomerBasicInfoEditor = ({
required required
/> />
<TextField
description={t("fields.tin.description")}
disabled={disabled}
label={t("fields.tin.label")}
name="tin"
placeholder={t("fields.tin.placeholder")}
readOnly={readOnly}
/>
<Controller <Controller
control={control} control={control}
name="isCompany" name="isCompany"
render={({ field, fieldState }) => { render={({ field, fieldState }) => {
return ( return (
<Field className="md:col-span-3" data-invalid={fieldState.invalid}> <Field data-invalid={fieldState.invalid}>
<div className="flex flex-col gap-2 pt-1">
<FormFieldLabel required>{t("fields.customer_type.label")}</FormFieldLabel> <FormFieldLabel required>{t("fields.customer_type.label")}</FormFieldLabel>
<RadioGroup <RadioGroup
className="gap-3" className="gap-3"
disabled={field.disabled} disabled={field.disabled}
name={field.name} name={field.name}
onValueChange={(value) => { onValueChange={(value: boolean) => {
field.onChange(value === "true"); field.onChange(value);
}} }}
required required
value={field.value ? "true" : "false"} value={field.value}
> >
<Field data-invalid={fieldState.invalid} orientation="horizontal"> <Field data-invalid={fieldState.invalid} orientation="horizontal">
<FieldContent> <FieldContent>
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="customer-type-company"
value="true"
/>
<FieldLabel <FieldLabel
className="cursor-pointer text-sm font-medium leading-none" className="cursor-pointer text-sm font-medium leading-none"
htmlFor="customer-type-company" htmlFor="customer-type-company"
> >
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="customer-type-company"
value={true}
/>
<Building2Icon className="size-4 text-muted-foreground" />
{t("fields.customer_type.options.company")} {t("fields.customer_type.options.company")}
</FieldLabel> </FieldLabel>
</FieldContent> </FieldContent>
@ -85,21 +95,22 @@ export const CustomerBasicInfoEditor = ({
<Field data-invalid={fieldState.invalid} orientation="horizontal"> <Field data-invalid={fieldState.invalid} orientation="horizontal">
<FieldContent> <FieldContent>
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="customer-type-individual"
value="false"
/>
<FieldLabel <FieldLabel
className="cursor-pointer text-sm font-medium leading-none" className="cursor-pointer text-sm font-medium leading-none"
htmlFor="customer-type-individual" htmlFor="customer-type-individual"
> >
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="customer-type-individual"
value={false}
/>
<UserIcon className="size-4 text-muted-foreground" />
{t("fields.customer_type.options.individual")} {t("fields.customer_type.options.individual")}
</FieldLabel> </FieldLabel>
</FieldContent> </FieldContent>
</Field> </Field>
</RadioGroup> </RadioGroup>
</div>
<FieldDescription>{t("fields.customer_type.description")}</FieldDescription> <FieldDescription>{t("fields.customer_type.description")}</FieldDescription>
<FieldError errors={[fieldState.error]} /> <FieldError errors={[fieldState.error]} />
</Field> </Field>
@ -108,18 +119,6 @@ export const CustomerBasicInfoEditor = ({
/> />
<TextField <TextField
className="md:col-start-1 md:col-span-4"
description={t("fields.tin.description")}
disabled={disabled}
label={t("fields.tin.label")}
name="tin"
placeholder={t("fields.tin.placeholder")}
readOnly={readOnly}
required
/>
<TextField
className="md:col-span-full"
description={t("fields.trade_name.description")} description={t("fields.trade_name.description")}
disabled={disabled} disabled={disabled}
label={t("fields.trade_name.label")} label={t("fields.trade_name.label")}
@ -129,7 +128,6 @@ export const CustomerBasicInfoEditor = ({
/> />
<TextField <TextField
className="md:col-span-6 md:col-start-1"
description={t("fields.reference.description")} description={t("fields.reference.description")}
disabled={disabled} disabled={disabled}
label={t("fields.reference.label")} label={t("fields.reference.label")}
@ -138,34 +136,7 @@ export const CustomerBasicInfoEditor = ({
readOnly={readOnly} readOnly={readOnly}
/> />
<Field className="md:col-span-6">
<Controller
control={control}
name="defaultTaxes"
render={({ field, fieldState }) => (
<Field className="gap-1" data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="defaultTaxes">
{t("fields.default_taxes.label")}
</FieldLabel>
<CustomerTaxesMultiSelect
description={t("fields.default_taxes.description")}
label={t("fields.default_taxes.label")}
onChange={field.onChange}
placeholder={t("fields.default_taxes.placeholder")}
required
value={field.value}
/>
<FieldDescription>{t("fields.default_taxes.description")}</FieldDescription>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</Field>
<TextAreaField <TextAreaField
className="md:col-span-full"
description={t("fields.legal_record.description")} description={t("fields.legal_record.description")}
disabled={disabled} disabled={disabled}
label={t("fields.legal_record.label")} label={t("fields.legal_record.label")}

View File

@ -1,15 +1,23 @@
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers"; import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { UseCustomerUpdateFiscalControllerResult } from "../../controllers";
import { CustomerAdditionalConfigEditor } from "./customer-additional-config-fields"; import { CustomerAdditionalConfigEditor } from "./customer-additional-config-fields";
import { CustomerAddressEditor } from "./customer-address-editor"; import { CustomerAddressEditor } from "./customer-address-editor";
import { CustomerBasicInfoEditor } from "./customer-basic-info-editor"; import { CustomerBasicInfoEditor } from "./customer-basic-info-editor";
import { CustomerContactEditor } from "./customer-contact-fields"; import { CustomerContactEditor } from "./customer-contact-fields";
import { CustomerFiscalEditor } from "./customer-fiscal-editor";
type CustomerUpdateEditorFormProps = { type CustomerUpdateEditorFormProps = {
formId: string; formId: string;
isSubmitting: boolean; isSubmitting: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>; onSubmit: React.SubmitEventHandler<HTMLFormElement>;
onReset: () => void; onReset: () => void;
fiscalCtrl: UseCustomerUpdateFiscalControllerResult;
className?: string;
}; };
export const CustomerUpdateEditorForm = ({ export const CustomerUpdateEditorForm = ({
@ -17,10 +25,15 @@ export const CustomerUpdateEditorForm = ({
isSubmitting, isSubmitting,
onSubmit, onSubmit,
onReset, onReset,
fiscalCtrl,
className,
}: CustomerUpdateEditorFormProps) => { }: CustomerUpdateEditorFormProps) => {
return ( return (
<div className={cn("p-6", className)}>
<form <form
className="space-y-6" className="space-y-6 2xl:space-y-12"
id={formId} id={formId}
noValidate noValidate
onKeyDown={preventEnterKeySubmitForm} onKeyDown={preventEnterKeySubmitForm}
@ -28,8 +41,14 @@ export const CustomerUpdateEditorForm = ({
> >
<CustomerBasicInfoEditor disabled={isSubmitting} /> <CustomerBasicInfoEditor disabled={isSubmitting} />
<CustomerAddressEditor /> <CustomerAddressEditor />
<CustomerFiscalEditor
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
paymentTermOptions={fiscalCtrl.paymentTermOptions}
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
/>
<CustomerContactEditor /> <CustomerContactEditor />
<CustomerAdditionalConfigEditor /> <CustomerAdditionalConfigEditor />
</form> </form>
</div>
); );
}; };

View File

@ -0,0 +1,93 @@
import {
FormSectionCard,
FormSectionGrid,
SelectField,
type SelectFieldItem,
SwitchField,
} from "@repo/rdx-ui/components";
import { LandmarkIcon } from "lucide-react";
import { useTranslation } from "../../../i18n";
interface CustomerFiscalEditorProps {
paymentMethodOptions: SelectFieldItem[];
paymentTermOptions: SelectFieldItem[];
taxRegimeOptions: SelectFieldItem[];
disabled?: boolean;
readOnly?: boolean;
}
export const CustomerFiscalEditor = ({
paymentMethodOptions,
paymentTermOptions,
taxRegimeOptions,
disabled = false,
readOnly = false,
}: CustomerFiscalEditorProps) => {
const { t } = useTranslation();
return (
<FormSectionCard
description={t("form_groups.fiscal.description")}
icon={<LandmarkIcon className="size-5" />}
title={t("form_groups.fiscal.title")}
>
<FormSectionGrid className="md:grid-cols-2">
<SelectField
clearable
description={t("fields.payment_method.description")}
disabled={disabled}
emptyValue={""}
items={paymentMethodOptions}
label={t("fields.payment_method.label")}
name="paymentMethodId"
placeholder={t("fields.payment_method.placeholder")}
readOnly={readOnly}
/>
<SelectField
clearable
description={t("fields.payment_term.description")}
disabled={disabled}
emptyValue={""}
items={paymentTermOptions}
label={t("fields.payment_term.label")}
name="paymentTermId"
placeholder={t("fields.payment_term.placeholder")}
readOnly={readOnly}
/>
<SelectField
className="col-span-full"
clearable
description={t("fields.tax_regime.description")}
disabled={disabled}
emptyValue={""}
items={taxRegimeOptions}
label={t("fields.tax_regime.label")}
name="taxRegimeCode"
placeholder={t("fields.tax_regime.placeholder")}
readOnly={readOnly}
/>
<SwitchField
className="flex items-center justify-between rounded-lg border border-border px-4 py-3"
description={t("fields.uses_equivalence_surcharge.description")}
disabled={disabled}
label={t("fields.uses_equivalence_surcharge.label")}
name="usesEquivalenceSurcharge"
readOnly={readOnly}
/>
<SwitchField
className="flex items-center justify-between rounded-lg border border-border px-4 py-3"
description={t("fields.uses_retention.description")}
disabled={disabled}
label={t("fields.uses_retention.label")}
name="usesRetention"
readOnly={readOnly}
/>
</FormSectionGrid>
</FormSectionCard>
);
};

View File

@ -1,7 +1,6 @@
import { ErrorAlert, NotFoundCard } from "@erp/core/components"; import { ErrorAlert, NotFoundCard } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components"; import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Spinner } from "@repo/shadcn-ui/components";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -81,16 +80,25 @@ export const CustomerUpdatePage = () => {
//onExportPdf={handleExportPdf} //onExportPdf={handleExportPdf}
/> />
{updateCtrl.isLoading && <Spinner />} {updateCtrl.isUpdateError && (
<ErrorAlert
{!updateCtrl.isLoading && ( message={
(updateCtrl.updateError as Error)?.message ??
t("pages.customers.update.error_msg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.customers.update.error_title", "No se pudo guardar los cambios")}
/>
)}
<div className="flex-1 overflow-y-auto">
<CustomerUpdateEditorForm <CustomerUpdateEditorForm
className="max-w-5xl mx-auto"
fiscalCtrl={updateCtrl.fiscalCtrl}
formId={updateCtrl.formId} formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating} isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm} onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit} onSubmit={updateCtrl.onSubmit}
/> />
)} </div>
</UnsavedChangesProvider> </UnsavedChangesProvider>
</FormProvider> </FormProvider>
</div> </div>

View File

@ -8,8 +8,6 @@ export const buildCustomerUpdateDefault = (): CustomerUpdateForm => {
tradeName: "", tradeName: "",
tin: "", tin: "",
defaultTaxes: [],
street: "", street: "",
street2: "", street2: "",
city: "", city: "",
@ -29,6 +27,13 @@ export const buildCustomerUpdateDefault = (): CustomerUpdateForm => {
legalRecord: "", legalRecord: "",
paymentMethodId: "",
paymentTermId: "",
taxRegimeCode: "",
usesEquivalenceSurcharge: false,
usesRetention: false,
languageCode: "es", languageCode: "es",
currencyCode: "EUR", currencyCode: "EUR",
}; };

View File

@ -47,10 +47,6 @@ export const buildCustomerUpdatePatch = (
patch.tin = toNullableText(formData.tin); patch.tin = toNullableText(formData.tin);
} }
if (dirtyFields.defaultTaxes) {
patch.defaultTaxes = formData.defaultTaxes;
}
if (dirtyFields.street) { if (dirtyFields.street) {
patch.street = toNullableText(formData.street); patch.street = toNullableText(formData.street);
} }
@ -111,6 +107,26 @@ export const buildCustomerUpdatePatch = (
patch.legalRecord = toNullableText(formData.legalRecord); patch.legalRecord = toNullableText(formData.legalRecord);
} }
if (dirtyFields.paymentMethodId) {
patch.paymentMethodId = toNullableText(formData.paymentMethodId);
}
if (dirtyFields.paymentTermId) {
patch.paymentTermId = toNullableText(formData.paymentTermId);
}
if (dirtyFields.taxRegimeCode) {
patch.taxRegimeCode = toNullableText(formData.taxRegimeCode);
}
if (dirtyFields.usesEquivalenceSurcharge) {
patch.usesEquivalenceSurcharge = formData.usesEquivalenceSurcharge;
}
if (dirtyFields.usesRetention) {
patch.usesRetention = formData.usesRetention;
}
if (dirtyFields.languageCode) { if (dirtyFields.languageCode) {
patch.languageCode = toRequiredText(formData.languageCode); patch.languageCode = toRequiredText(formData.languageCode);
} }
@ -119,6 +135,8 @@ export const buildCustomerUpdatePatch = (
patch.currencyCode = toRequiredText(formData.currencyCode); patch.currencyCode = toRequiredText(formData.currencyCode);
} }
console.log(dirtyFields, patch);
return patch; return patch;
}; };

View File

@ -46,10 +46,6 @@ export const buildUpdateCustomerByIdParams = (
data.tin = patchData.tin; data.tin = patchData.tin;
} }
if (patchData.defaultTaxes !== undefined) {
data.default_taxes = patchData.defaultTaxes?.toString();
}
if (patchData.legalRecord !== undefined) { if (patchData.legalRecord !== undefined) {
data.legal_record = patchData.legalRecord; data.legal_record = patchData.legalRecord;
} }
@ -62,6 +58,27 @@ export const buildUpdateCustomerByIdParams = (
data.currency_code = patchData.currencyCode; data.currency_code = patchData.currencyCode;
} }
// --- legal ---
if (patchData.paymentMethodId !== undefined) {
data.payment_method_id = patchData.paymentMethodId;
}
if (patchData.paymentTermId !== undefined) {
data.payment_term_id = patchData.paymentTermId;
}
if (patchData.taxRegimeCode !== undefined) {
data.tax_regime_code = patchData.taxRegimeCode;
}
if (patchData.usesEquivalenceSurcharge !== undefined) {
data.uses_equivalence_surcharge = patchData.usesEquivalenceSurcharge;
}
if (patchData.usesRetention !== undefined) {
data.uses_retention = patchData.usesRetention;
}
// --- address --- // --- address ---
const address: Record<string, unknown> = {}; const address: Record<string, unknown> = {};

View File

@ -1,9 +1,18 @@
import { useUrlParamId } from "@erp/core/hooks";
import { useSearchParams } from "react-router-dom";
import { useCustomerViewController } from "./use-customer-view.controller"; import { useCustomerViewController } from "./use-customer-view.controller";
export function useCustomerViewPageController() { export function useCustomerViewPageController() {
const viewCtrl = useCustomerViewController(); const customerId = useUrlParamId();
const [searchParams] = useSearchParams();
const viewCtrl = useCustomerViewController(customerId);
const returnTo = searchParams.get("returnTo") ?? "/customers";
return { return {
viewCtrl, viewCtrl,
returnTo,
}; };
} }

View File

@ -1,23 +1,24 @@
import { useState } from "react";
import { useCustomerGetQuery } from "../../shared/hooks"; import { useCustomerGetQuery } from "../../shared/hooks";
export const useCustomerViewController = (initialCustomerId = "") => { export const useCustomerViewController = (customerId?: string) => {
const [customerId, setCustomerId] = useState(initialCustomerId); const {
data: customerData,
const query = useCustomerGetQuery(customerId); isLoading,
isError: isLoadError,
error: loadError,
refetch,
} = useCustomerGetQuery({
id: customerId,
enabled: Boolean(customerId),
});
return { return {
data: query.data, // carga de datos
isLoading: query.isLoading, customerData,
isFetching: query.isFetching, isLoading,
isLoadError,
loadError,
isError: query.isError, refetch,
error: query.error,
refetch: query.refetch,
customerId,
setCustomerId,
}; };
}; };

View File

@ -0,0 +1,110 @@
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components";
import {
CancelActionButton,
FormActionsBar,
type FormSecondaryAction,
FormSecondaryActionsMenu,
} from "@repo/rdx-ui/components";
import type { ReactNode } from "react";
export interface CustomerViewHeaderLabels {
title: string;
back: string;
modified: string;
cancel: string;
save: string;
saving: string;
moreActions: string;
keyboardShortcuts: string;
delete: string;
}
export interface CustomerViewHeaderProps {
labels: CustomerViewHeaderLabels;
onCancel?: () => void;
onDuplicate?: () => void;
onDelete?: () => void;
disabled?: boolean;
readOnly?: boolean;
isSaving?: boolean;
hasChanges?: boolean;
className?: string;
children?: ReactNode;
}
export const CustomerViewHeader = ({
labels,
onCancel,
onDuplicate,
onDelete,
disabled = false,
readOnly = false,
isSaving = false,
hasChanges = false,
className,
children,
}: CustomerViewHeaderProps) => {
const computedDisabled = disabled || isSaving;
const secondaryActions: FormSecondaryAction[] = [
/*{
id: "duplicate",
label: labels.duplicate,
icon: <CopyIcon aria-hidden="true" className="mr-2 size-4" />,
hidden: !onDuplicate,
disabled: computedDisabled,
onSelect: () => onDuplicate?.(),
},
{
id: "delete",
label: labels.delete,
icon: <Trash2Icon aria-hidden="true" className="mr-2 size-4" />,
hidden: !onDelete || readOnly,
disabled: computedDisabled,
destructive: true,
onSelect: () => onDelete?.(),
},*/
];
return (
<PageFormHeader
actions={
<FormActionsBar align="end" reverseOnMobile={false}>
<PageKeyboardShortcutsButton
className="hidden sm:flex"
label={labels.keyboardShortcuts}
shortcuts={[
{ keys: "Ctrl+S", label: labels.save },
{ keys: "Esc", label: labels.cancel },
]}
/>
<CancelActionButton
className="hidden sm:flex"
disabled={computedDisabled}
label={labels.cancel}
onCancel={() => onCancel?.()}
variant="outline"
/>
<FormSecondaryActionsMenu
actions={secondaryActions}
disabled={computedDisabled}
label={labels.moreActions}
/>
</FormActionsBar>
}
backLabel={labels.back}
className={className}
onBack={onCancel}
showStatus={hasChanges}
statusLabel={labels.modified}
title={labels.title}
>
{children}
</PageFormHeader>
);
};

View File

@ -0,0 +1 @@
export * from "./customer-view-header";

View File

@ -1,61 +1,44 @@
import { ErrorAlert, PageHeader } from "@erp/core/components"; import { ErrorAlert, NotFoundCard } from "@erp/core/components";
import { useUrlParamId } from "@erp/core/hooks"; import { useReturnToNavigation } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { import { Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import { import {
Banknote, Banknote,
EditIcon,
FileText, FileText,
Globe, Globe,
Languages, Languages,
Mail, Mail,
MapPin, MapPin,
MoreVertical,
Phone, Phone,
Smartphone, Smartphone,
} from "lucide-react"; } from "lucide-react";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { CustomerEditorSkeleton } from "../../../update/ui/components"; import { CustomerEditorSkeleton } from "../../../update/ui/components";
import { useCustomerViewPageController } from "../../controllers"; import { useCustomerViewPageController } from "../../controllers";
import { CustomerViewHeader } from "../blocks";
export const CustomerViewPage = () => { export const CustomerViewPage = () => {
const initialCustomerId = useUrlParamId();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const { const { viewCtrl, returnTo } = useCustomerViewPageController();
viewCtrl: { setCustomerId, customerId, data: customerData, isError, error, isLoading },
} = useCustomerViewPageController();
useEffect(() => { const { navigateBack } = useReturnToNavigation({
if (initialCustomerId && customerId !== initialCustomerId) { fallbackPath: returnTo,
setCustomerId(initialCustomerId); });
}
}, [initialCustomerId, customerId, setCustomerId]);
if (isLoading) { const handleCancel = () => navigateBack();
return <CustomerEditorSkeleton />;
if (viewCtrl.isLoading) {
return <CustomerEditorSkeleton />; /*<CustomerViewSkeleton />;*/
} }
if (isError) { if (viewCtrl.isLoadError) {
return ( return (
<> <>
<AppContent> <AppContent>
<ErrorAlert <ErrorAlert
message={ message={(viewCtrl.loadError as Error)?.message ?? t("view.errors.load_message")}
(error as Error)?.message ??
t("view.errors.load_message")
}
title={t("view.errors.load_title")} title={t("view.errors.load_title")}
/> />
@ -67,55 +50,40 @@ export const CustomerViewPage = () => {
); );
} }
if (!viewCtrl.customerData)
return ( return (
<>
<AppHeader>
<PageHeader
backIcon
description={
<div className="mt-2 flex items-center gap-3">
<Badge className="font-mono" variant="secondary">
{customerData?.tin}
</Badge>
<Badge variant="outline">
{customerData?.isCompany
? t("fields.customer_type.options.company")
: t("fields.customer_type.options.individual")}
</Badge>
</div>
}
rightSlot={
<div className="flex gap-2">
<Button
className="cursor-pointer"
onClick={() => navigate("/customers/list")}
size="icon"
variant="outline"
>
<MoreVertical className="h-4 w-4" />
</Button>
<Button
className="cursor-pointer"
onClick={() => navigate(`/customers/${customerId}/edit`)}
>
<EditIcon className="mr-2 h-4 w-4" />
{t("view.actions.edit")}
</Button>
</div>
}
title={
<div className="flex flex-wrap items-center gap-2">
{customerData?.name}{" "}
{customerData?.tradeName && (
<span className="text-muted-foreground">({customerData.tradeName})</span>
)}
</div>
}
/>
</AppHeader>
<AppContent> <AppContent>
{/* Main Content Grid */} <NotFoundCard
<div className="grid gap-6 md:grid-cols-2"> message={t(
"pages.customers.view.not_found_msg",
"Revisa el identificador o vuelve al listado."
)}
title={t("pages.customers.view.not_found_title", "Customer not found")}
/>
</AppContent>
);
return (
<div className="fixed inset-0 flex flex-col overflow-hidden">
<CustomerViewHeader
labels={{
title: "Ver cliente",
back: "Volver",
modified: "Modificada",
cancel: "Cancelar",
save: "Guardar",
saving: "Guardando...",
moreActions: "Más acciones",
keyboardShortcuts: "Ver atajos de teclado",
delete: "Eliminar",
}}
onCancel={handleCancel}
//onDelete={openDeleteDialog}
//onDuplicate={handleDuplicate}
//onExportPdf={handleExportPdf}
/>
<div className="flex-1 overflow-y-auto">
{/* Información Básica */} {/* Información Básica */}
<Card> <Card>
<CardHeader> <CardHeader>
@ -126,35 +94,23 @@ export const CustomerViewPage = () => {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">{t("view.fields.name")}</dt>
{t("view.fields.name")} <dd className="mt-1 text-base text-foreground">{viewCtrl.customerData?.name}</dd>
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.name}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.reference")} {t("view.fields.reference")}
</dt> </dt>
<dd className="mt-1 font-mono text-base text-foreground"> <dd className="mt-1 font-mono text-base text-foreground">
{customerData?.reference} {viewCtrl.customerData?.reference}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.legal_record")} {t("view.fields.legal_record")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground">{customerData?.legalRecord}</dd> <dd className="mt-1 text-base text-foreground">
</div> {viewCtrl.customerData?.legalRecord}
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.default_taxes")}
</dt>
<dd className="mt-1">
{customerData?.defaultTaxes.map((tax: string) => (
<Badge key={tax} variant={"secondary"}>
{tax}
</Badge>
))}
</dd> </dd>
</div> </div>
</CardContent> </CardContent>
@ -174,11 +130,11 @@ export const CustomerViewPage = () => {
{t("view.fields.street")} {t("view.fields.street")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.street} {viewCtrl.customerData?.street}
{customerData?.street2 && ( {viewCtrl.customerData?.street2 && (
<> <>
<br /> <br />
{customerData?.street2} {viewCtrl.customerData?.street2}
</> </>
)} )}
</dd> </dd>
@ -188,13 +144,15 @@ export const CustomerViewPage = () => {
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.city")} {t("view.fields.city")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground">{customerData?.city}</dd> <dd className="mt-1 text-base text-foreground">{viewCtrl.customerData?.city}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.postal_code")} {t("view.fields.postal_code")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground">{customerData?.postalCode}</dd> <dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.postalCode}
</dd>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -202,13 +160,15 @@ export const CustomerViewPage = () => {
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.province")} {t("view.fields.province")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground">{customerData?.province}</dd> <dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.province}
</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.country")} {t("view.fields.country")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground">{customerData?.country}</dd> <dd className="mt-1 text-base text-foreground">{viewCtrl.customerData?.country}</dd>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -229,7 +189,7 @@ export const CustomerViewPage = () => {
<h3 className="font-semibold text-foreground"> <h3 className="font-semibold text-foreground">
{t("view.sections.primary_contact")} {t("view.sections.primary_contact")}
</h3> </h3>
{customerData?.primaryEmail && ( {viewCtrl.customerData?.primaryEmail && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
@ -237,12 +197,12 @@ export const CustomerViewPage = () => {
{t("view.fields.email")} {t("view.fields.email")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.primaryEmail} {viewCtrl.customerData?.primaryEmail}
</dd> </dd>
</div> </div>
</div> </div>
)} )}
{customerData?.primaryMobile && ( {viewCtrl.customerData?.primaryMobile && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
@ -250,12 +210,12 @@ export const CustomerViewPage = () => {
{t("view.fields.mobile")} {t("view.fields.mobile")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.primaryMobile} {viewCtrl.customerData?.primaryMobile}
</dd> </dd>
</div> </div>
</div> </div>
)} )}
{customerData?.primaryPhone && ( {viewCtrl.customerData?.primaryPhone && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
@ -263,7 +223,7 @@ export const CustomerViewPage = () => {
{t("view.fields.phone")} {t("view.fields.phone")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.primaryPhone} {viewCtrl.customerData?.primaryPhone}
</dd> </dd>
</div> </div>
</div> </div>
@ -275,7 +235,7 @@ export const CustomerViewPage = () => {
<h3 className="font-semibold text-foreground"> <h3 className="font-semibold text-foreground">
{t("view.sections.secondary_contact")} {t("view.sections.secondary_contact")}
</h3> </h3>
{customerData?.secondaryEmail && ( {viewCtrl.customerData?.secondaryEmail && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
@ -283,12 +243,12 @@ export const CustomerViewPage = () => {
{t("view.fields.email")} {t("view.fields.email")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.secondaryEmail} {viewCtrl.customerData?.secondaryEmail}
</dd> </dd>
</div> </div>
</div> </div>
)} )}
{customerData?.secondaryMobile && ( {viewCtrl.customerData?.secondaryMobile && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
@ -296,12 +256,12 @@ export const CustomerViewPage = () => {
{t("view.fields.mobile")} {t("view.fields.mobile")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.secondaryMobile} {viewCtrl.customerData?.secondaryMobile}
</dd> </dd>
</div> </div>
</div> </div>
)} )}
{customerData?.secondaryPhone && ( {viewCtrl.customerData?.secondaryPhone && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
@ -309,7 +269,7 @@ export const CustomerViewPage = () => {
{t("view.fields.phone")} {t("view.fields.phone")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.secondaryPhone} {viewCtrl.customerData?.secondaryPhone}
</dd> </dd>
</div> </div>
</div> </div>
@ -317,13 +277,13 @@ export const CustomerViewPage = () => {
</div> </div>
{/* Otros Contactos */} {/* Otros Contactos */}
{(customerData?.website || customerData?.fax) && ( {(viewCtrl.customerData?.website || viewCtrl.customerData?.fax) && (
<div className="space-y-4 md:col-span-2"> <div className="space-y-4 md:col-span-2">
<h3 className="font-semibold text-foreground"> <h3 className="font-semibold text-foreground">
{t("view.sections.other_contacts")} {t("view.sections.other_contacts")}
</h3> </h3>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{customerData?.website && ( {viewCtrl.customerData?.website && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Globe className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Globe className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
@ -332,24 +292,26 @@ export const CustomerViewPage = () => {
</dt> </dt>
<dd className="mt-1 text-base text-primary hover:underline"> <dd className="mt-1 text-base text-primary hover:underline">
<a <a
href={customerData?.website} href={viewCtrl.customerData?.website}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
{customerData?.website} {viewCtrl.customerData?.website}
</a> </a>
</dd> </dd>
</div> </div>
</div> </div>
)} )}
{customerData?.fax && ( {viewCtrl.customerData?.fax && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.fax")} {t("view.fields.fax")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground">{customerData?.fax}</dd> <dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.fax}
</dd>
</div> </div>
</div> </div>
)} )}
@ -376,7 +338,9 @@ export const CustomerViewPage = () => {
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.language_code")} {t("view.fields.language_code")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground">{customerData?.languageCode}</dd> <dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.languageCode}
</dd>
</div> </div>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@ -385,14 +349,15 @@ export const CustomerViewPage = () => {
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.currency_code")} {t("view.fields.currency_code")}
</dt> </dt>
<dd className="mt-1 text-base text-foreground">{customerData?.currencyCode}</dd> <dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.currencyCode}
</dd>
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</AppContent> </div>
</>
); );
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "@erp/factuges", "name": "@erp/factuges",
"version": "0.6.9", "version": "0.7.3",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"@erp/auth": "workspace:*", "@erp/auth": "workspace:*",
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*",
"@erp/customer-invoices": "workspace:*", "@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*", "@erp/customers": "workspace:*",
"@repo/i18next": "workspace:*", "@repo/i18next": "workspace:*",

View File

@ -0,0 +1,35 @@
import type {
IPaymentMethodPublicFinder,
IPaymentTermPublicFinder,
ITaxDefinitionPublicFinder,
ITaxRegimePublicFinder,
} from "@erp/catalogs/api";
import { FactuGESPaymentResolver } from "../services";
import type { FactuGESPaymentMethodReferenceProvider } from "../services/json-data";
export interface IFactuGESCatalogResolvers {
//taxResolver: FactuGESTaxResolver;
paymentResolver: FactuGESPaymentResolver;
}
export function buildFactuGESCatalogResolvers(params: {
taxDefinitionFinder: ITaxDefinitionPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
paymentReferenceProvider: FactuGESPaymentMethodReferenceProvider;
}): IFactuGESCatalogResolvers {
return {
/*taxResolver: buildFactuGESTaxResolver({
taxDefinitionFinder: params.taxDefinitionFinder,
taxRegimeFinder: params.taxRegimeFinder,
}),*/
paymentResolver: new FactuGESPaymentResolver({
paymentMethodFinder: params.paymentMethodFinder,
paymentTermFinder: params.paymentTermFinder,
paymentReferenceProvider: params.paymentReferenceProvider,
}),
};
}

Some files were not shown because too many files have changed in this diff Show More