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",
"version": "0.6.9",
"version": "0.7.3",
"private": true,
"scripts": {
"build": "tsup src/index.ts --config tsup.config.ts",

View File

@ -36,7 +36,7 @@ export function getServiceScoped<T = unknown>(
if (!allowedDeps.includes(serviceModule)) {
throw new Error(
`❌ 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 {
const service = services[name];
if (!service) {
throw new Error(`❌ Servicio "${name}" no encontrado.`);
throw new Error(`❌ Service "${name}" not found.`);
}
return service as T;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,8 @@ import {
EnablePaymentTermByIdUseCase,
GetPaymentTermByIdUseCase,
ListPaymentTermsUseCase,
PaymentTermPublicFinder,
PaymentTermPublicModelMapper,
UpdatePaymentTermByIdUseCase,
buildPaymentTermCreator,
buildPaymentTermDeleter,
@ -19,7 +21,6 @@ import {
buildPaymentTermUpdater,
} from "../../../application/payment-terms";
import type { IPaymentTermRepository } from "../../../application/payment-terms/repositories";
import { PaymentTermFinder } from "../../../application/payment-terms/services";
import { buildPaymentTermPersistenceMappers } from "./payment-term-persistence-mappers.di";
import { buildPaymentTermRepository } from "./payment-term-repositories.di";
@ -110,8 +111,10 @@ export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTerm
export const buildPaymentTermsPublicServices = (
_params: SetupParams,
deps: PaymentTermsInternalDeps
): { finder: PaymentTermFinder } => {
): { finder: PaymentTermPublicFinder } => {
const mapper = new PaymentTermPublicModelMapper();
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 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(
TaxRateVO.create({ value: raw.rate_value, scale: raw.rate_scale }),
@ -60,33 +64,57 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
errors
);
const jurisdictionRegionCode = maybeFromNullableResult(raw.jurisdiction_region_code, (v) =>
CountryRegionCode.create(v)
const jurisdictionRegionCode = extractOrPushError(
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 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) => {
if (!Array.isArray(v)) return Result.fail(new Error("Invalid allowed_surcharge_codes"));
const allowedSurchargeCodes = extractOrPushError(
maybeFromNullableResult(raw.allowed_surcharge_codes, (v) => {
console.log(v);
const arr: any[] = [];
for (const el of v) {
const _result = TaxDefinitionCodeVO.create(String(el));
if (_result.isFailure)
return Result.fail(new Error("Invalid allowed_surcharge_codes element"));
arr.push(_result.data);
}
const values = v.split(";");
if (!Array.isArray(values))
return Result.fail(new Error("Invalid allowed_surcharge_codes"));
return Result.ok(arr);
});
const arr: any[] = [];
for (const el of values) {
const _result = TaxDefinitionCodeVO.create(String(el));
if (_result.isFailure)
return Result.fail(new Error("Invalid allowed_surcharge_codes element"));
arr.push(_result.data);
}
return Result.ok(arr);
}),
"allowed_surcharge_codes",
errors
);
// valid from / to
const validFrom = maybeFromNullableResult(raw.valid_from, (v) => Result.ok(String(v)));
const validTo = maybeFromNullableResult(raw.valid_to, (v) => Result.ok(String(v)));
const validFrom = extractOrPushError(
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) {
console.error(errors);
return Result.fail(
new ValidationErrorCollection("TaxDefinition mapping failed [mapToDomain]", errors)
);
@ -130,8 +158,8 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
(v) => v.toPrimitive(),
() => null
),
rate_value: source.rate.toPrimitive(),
rate_scale: (source.rate as any).scale ?? 2,
rate_value: source.rate.toPrimitive().value,
rate_scale: source.rate.toPrimitive().scale,
tax_family: source.taxFamily.toPrimitive(),
calculation_behavior: source.calculationBehavior.toPrimitive(),
jurisdiction_country_code: source.jurisdictionCountryCode.toPrimitive(),
@ -145,7 +173,7 @@ export class SequelizeTaxDefinitionDomainMapper extends SequelizeDomainMapper<
() => null
),
allowed_surcharge_codes: source.allowedSurchargeCodes.match(
(arr) => arr.map((c) => c.toPrimitive()),
(arr) => arr.map((c) => c.toPrimitive()).join(";"),
() => null
),
is_system: source.isSystem,

View File

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

View File

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

View File

@ -12,7 +12,7 @@ export const globalErrorHandler = async (
res: Response,
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
if (res.headersSent) {

View File

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

View File

@ -8,14 +8,13 @@ import { z } from "zod/v4";
*
* Ejemplos válidos:
* - "28013" (ES)
* - "SW1A 1AA" (UK)
* - "75008" (FR)
* - "10115" (DE)
*/
export const PostalCodeSchema = z
.string()
.min(1, "Postal code cannot be empty")
.max(16, "Postal code too long")
.regex(/^[A-Za-z0-9\- ]+$/, "Invalid postal code format");
.max(5, "Postal code too long")
.regex(/^[0-9\- ]+$/, "Invalid postal code format");
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 "./dto";
export * from "./helpers";
export * from "./json-data";
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",
"version": "0.6.9",
"version": "0.7.3",
"private": true,
"type": "module",
"sideEffects": false,

View File

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

View File

@ -146,7 +146,7 @@ export class CreateProformaInputMapper implements ICreateProformaInputMapper {
companyId: params.companyId,
status: InvoiceStatus.draft(),
invoiceNumber: invoiceNumber!,
//invoiceNumber: invoiceNumber!,
series: series!,
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.
*/
export type ProformaCreateInputProps = Omit<IProformaCreateProps, "items"> & {
export type ProformaCreateInputProps = Omit<IProformaCreateProps, "items" | "invoiceNumber"> & {
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 Maybe, Result } from "@repo/rdx-utils";
@ -26,13 +26,10 @@ export class ProformaPaymentResolver {
public constructor(
private readonly deps: {
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
public async ensurePaymentMethodById(params: {
companyId: UniqueID;
@ -59,4 +56,31 @@ export class ProformaPaymentResolver {
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(
props: ProformaCreateInputProps
): Promise<Result<IProformaCreateProps, Error>> {
): Promise<Result<Omit<IProformaCreateProps, "invoiceNumber">, Error>> {
// Tax Regime => comprobar que existe
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({
companyId: props.companyId,

View File

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

View File

@ -11,6 +11,9 @@ type ProformaCatalogsDeps = {
paymentMethod: {
finder: CatalogsPublicServicesType["paymentMethods"]["finder"];
};
paymentTerm: {
finder: CatalogsPublicServicesType["paymentTerms"]["finder"];
};
};
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");
}
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,
@ -45,5 +55,8 @@ export function resolveProformaCatalogsDeps(params: ModuleParams): ProformaCatal
paymentMethod: {
finder: paymentMethod.finder,
},
paymentTerm: {
finder: paymentTerm.finder,
},
};
}

View File

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

View File

@ -57,7 +57,11 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
layout: "app-fullscreen",
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";
@ -13,7 +13,7 @@ export const CustomerInvoicesModuleManifest: IModuleClient = {
protected: true, // protegido por defecto
layout: "app-sidebar", // layout por defecto
routes: (params) => CustomerInvoiceRoutes(params),
routes: (params: ModuleClientParams) => CustomerInvoiceRoutes(params),
};
export default CustomerInvoicesModuleManifest;

View File

@ -1,5 +1,9 @@
import type { PropsWithChildren } from "react";
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);
} catch (error: unknown) {
console.log(error);
const normalizedError = normalizeSubmitError(error);
// No revierto el form para que no se pierdan los cambios que
@ -221,7 +222,6 @@ export const useUpdateProformaController = (
}
},
(errors: FieldErrors<ProformaUpdateForm>) => {
console.error(errors);
focusFirstInputFormError(form);
showWarningToast(

View File

@ -33,9 +33,6 @@ type ProformaUpdateEditorProps = {
totalsCtrl: UseUpdateProformaTotalsControllerResult;
paymentCtrl: UseUpdateProformaPaymentControllerResult;
currencyCode?: string;
languageCode?: string;
className?: string;
};
@ -51,57 +48,51 @@ export const ProformaUpdateEditorForm = ({
taxCtrl,
totalsCtrl,
paymentCtrl,
currencyCode,
languageCode,
className,
}: ProformaUpdateEditorProps) => {
const { t } = useTranslation();
return (
<form
className={cn("space-y-6 2xl:space-y-12", className)}
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
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} />
<div className={cn("p-6", className)}>
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
<div className="grid grid-cols-1 2xl:grid-cols-4 gap-6 2xl:gap-12">
<div className="2xl:col-span-3 space-y-6 gap-6 2xl:gap-12 2xl:space-y-12">
<div className="grid 2xl:grid-cols-3 gap-6 2xl:gap-6 space-y-12 2xl:space-y-12">
<ProformaUpdateHeaderEditor className="2xl:col-span-full" disabled={isSubmitting} />
</div>
<ProformaUpdateItemsEditor
disabled={isSubmitting}
itemsCtrl={itemsCtrl}
taxCtrl={taxCtrl}
totalsCtrl={totalsCtrl}
/>
</div>
<div className="2xl:col-span-1 space-y-6 2xl:space-y-12">
<ProformaUpdateRecipientEditor
className="2xl:col-span-1"
disabled={isSubmitting}
onChangeCustomerClick={onChangeCustomerClick}
onCreateCustomerClick={onCreateCustomerClick}
selectedCustomer={selectedCustomer}
/>
<ProformaUpdateItemsEditor
disabled={isSubmitting}
itemsCtrl={itemsCtrl}
taxCtrl={taxCtrl}
totalsCtrl={totalsCtrl}
/>
<ProformaUpdateTaxEditor
className="2xl:col-span-1"
taxCtrl={taxCtrl}
taxRegimeOptions={taxCtrl.taxRegimeOptions}
/>
<ProformaUpdatePaymentEditor
className="w-full"
disabled={isSubmitting || paymentCtrl.isLoading}
paymentMethodOptions={paymentCtrl.paymentMethodOptions}
paymentTermOptions={paymentCtrl.paymentTermOptions}
/>
</div>
</div>
<div className="2xl:col-span-1 space-y-4 2xl:space-y-6">
<ProformaUpdateRecipientEditor
className="2xl:col-span-1"
disabled={isSubmitting}
onChangeCustomerClick={onChangeCustomerClick}
onCreateCustomerClick={onCreateCustomerClick}
selectedCustomer={selectedCustomer}
/>
<ProformaUpdateTaxEditor
className="2xl:col-span-1"
taxCtrl={taxCtrl}
taxRegimeOptions={taxCtrl.taxRegimeOptions}
/>
<ProformaUpdatePaymentEditor
className="w-full"
disabled={isSubmitting || paymentCtrl.isLoading}
paymentMethodOptions={paymentCtrl.paymentMethodOptions}
paymentTermOptions={paymentCtrl.paymentTermOptions}
/>
</div>
</div>
{/* Footer fijo con totales */}
</form>
{/* Footer fijo con totales */}
</form>
</div>
);
};

View File

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

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "@erp/customers",
"description": "Customers",
"version": "0.6.9",
"version": "0.7.3",
"private": true,
"type": "module",
"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 {
CreateCustomerInputMapper,
type ICreateCustomerInputMapper,
@ -12,11 +10,9 @@ export interface ICustomerInputMappers {
updateInputMapper: IUpdateCustomerInputMapper;
}
export const buildCustomerInputMappers = (catalogs: ICatalogs): ICustomerInputMappers => {
const { taxCatalog } = catalogs;
export const buildCustomerInputMappers = (): ICustomerInputMappers => {
// 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();
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 { ICustomerCreator, ICustomerFinder, ICustomerUpdater } from "../services";
import type { ICustomerFullReadModelAssembler } from "../services/assemblers";
import type {
ICustomerFullSnapshotBuilder,
ICustomerSummarySnapshotBuilder,
@ -15,10 +16,11 @@ import {
export function buildGetCustomerByIdUseCase(deps: {
finder: ICustomerFinder;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new GetCustomerByIdUseCase(deps.finder, deps.fullSnapshotBuilder, deps.transactionManager);
return new GetCustomerByIdUseCase(deps);
}
export function buildListCustomersUseCase(deps: {
@ -36,29 +38,21 @@ export function buildListCustomersUseCase(deps: {
export function buildCreateCustomerUseCase(deps: {
creator: ICustomerCreator;
dtoMapper: ICreateCustomerInputMapper;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new CreateCustomerUseCase({
dtoMapper: deps.dtoMapper,
creator: deps.creator,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
transactionManager: deps.transactionManager,
});
return new CreateCustomerUseCase(deps);
}
export function buildUpdateCustomerUseCase(deps: {
updater: ICustomerUpdater;
dtoMapper: IUpdateCustomerInputMapper;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new UpdateCustomerUseCase({
dtoMapper: deps.dtoMapper,
updater: deps.updater,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
transactionManager: deps.transactionManager,
});
return new UpdateCustomerUseCase(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-finder.di";
export * from "./customer-input-mappers.di";
export * from "./customer-read-model-assemblers.di";
export * from "./customer-snapshot-builders.di";
export * from "./customer-updater.di";
export * from "./customer-use-cases.di";

View File

@ -1,4 +1,3 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import {
City,
Country,
@ -35,12 +34,6 @@ export interface ICreateCustomerInputMapper {
}
export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
private readonly taxCatalog: JsonTaxCatalogProvider;
constructor(params: { taxCatalog: JsonTaxCatalogProvider }) {
this.taxCatalog = params.taxCatalog;
}
public map(
dto: CreateCustomerRequestDTO,
params: { companyId: UniqueID }
@ -52,7 +45,7 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
const customerId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
const status = CustomerStatus.createActive();
const isCompany = dto.is_company === "true";
const isCompany = Boolean(dto.is_company);
const reference = extractOrPushError(
maybeFromNullableResult(dto.reference, (value) => Name.create(value)),
@ -164,6 +157,27 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
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(
LanguageCode.create(dto.language_code),
"language_code",
@ -178,15 +192,6 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
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) {
return Result.fail(new ValidationErrorCollection("Customer props mapping failed", errors));
}
@ -225,7 +230,14 @@ export class CreateCustomerInputMapper implements ICreateCustomerInputMapper {
website: website!,
legalRecord: legalRecord!,
defaultTaxes: defaultTaxes!,
paymentMethodId: paymentMethodId!,
paymentTermId: paymentTermId!,
taxRegimeCode: taxRegimeCode!,
usesEquivalenceSurcharge: usesEquivalenceSurcharge,
usesRetention: usesRetention,
languageCode: languageCode!,
currencyCode: currencyCode!,
};

View File

@ -1,2 +1,3 @@
export * from "./create-customer-input.mapper";
export * from "./tax-definition-to-tax.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,
TextValue,
URLAddress,
type UniqueID,
UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
@ -115,30 +115,55 @@ export class UpdateCustomerInputMapper implements IUpdateCustomerInputMapper {
);
});
/**
* Se mantiene la compatibilidad con el contrato actual.
* Cuando defaultTaxes tenga un contrato final estable, aquí conviene
* mapearlo a CustomerTaxes/Collection<TaxCode> con su schema semántico real.
*/
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.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);
this.throwIfValidationErrors(errors);
if (addressPatchProps) {
customerPatchProps.address = addressPatchProps;
}
this.mapContact(dto.contact, customerPatchProps, errors);
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Customer props mapping failed (update)", errors)
);
}
this.throwIfValidationErrors(errors);
console.log("dto => ", dto);
console.log("customerPatchProps => ", customerPatchProps);
return Result.ok(customerPatchProps);
} 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 { ICreateCustomerInputMapper } from "../mappers";
import type { ICustomerCreator } from "../services";
import type { ICustomerFullReadModelAssembler } from "../services/assemblers";
import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders";
type CreateCustomerUseCaseInput = {
@ -12,47 +13,46 @@ type CreateCustomerUseCaseInput = {
dto: CreateCustomerRequestDTO;
};
type CreateCustomerUseCaseDeps = {
dtoMapper: ICreateCustomerInputMapper;
creator: ICustomerCreator;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
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;
}
constructor(
private readonly deps: {
dtoMapper: ICreateCustomerInputMapper;
creator: ICustomerCreator;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}
) {}
public execute(params: CreateCustomerUseCaseInput) {
const { dto, companyId } = params;
const mappedPropsResult = this.dtoMapper.map(dto, { companyId });
const mappedPropsResult = this.deps.dtoMapper.map(dto, { companyId });
if (mappedPropsResult.isFailure) {
return mappedPropsResult;
}
const { props, id } = mappedPropsResult.data;
return this.transactionManager.complete(async (transaction: unknown) => {
return this.deps.transactionManager.complete(async (transaction: unknown) => {
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) {
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);
} catch (error: unknown) {

View File

@ -1,4 +1,4 @@
export * from "./create-customer.use-case";
export * from "./get-customer-by-id.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 = {
name: "customers",
version: "1.0.0",
dependencies: [],
dependencies: ["catalogs"],
/**
* 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";
export interface ICustomerPersistenceMappers {
@ -9,13 +7,9 @@ export interface ICustomerPersistenceMappers {
//createMapper: CreateCustomerInputMapper;
}
export const buildCustomerPersistenceMappers = (
catalogs: ICatalogs
): ICustomerPersistenceMappers => {
const { taxCatalog } = catalogs;
export const buildCustomerPersistenceMappers = (): ICustomerPersistenceMappers => {
// Mappers para el repositorio
const domainMapper = new SequelizeCustomerDomainMapper({ taxCatalog });
const domainMapper = new SequelizeCustomerDomainMapper();
const summaryMapper = new SequelizeCustomerSummaryMapper();
// 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 { Result } from "@repo/rdx-utils";
@ -10,6 +10,7 @@ import {
} from "../../application";
import type { ICustomerCreateProps } from "../../domain";
import { resolveCustomerCatalogsDeps } from "./customer-catalog-deps.di";
import { buildCustomerPersistenceMappers } from "./customer-persistence-mappers.di";
import { buildCustomerRepository } from "./customer-repositories.di";
import type { CustomersInternalDeps } from "./customers.di";
@ -19,10 +20,10 @@ export function buildCustomerPublicServices(
deps: CustomersInternalDeps
): ICustomerPublicServices {
const { database } = params;
const catalogs = buildCatalogs();
const catalogs = resolveCustomerCatalogsDeps(params);
// Infrastructure
const persistenceMappers = buildCustomerPersistenceMappers(catalogs);
const persistenceMappers = buildCustomerPersistenceMappers();
const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
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 {
type CreateCustomerUseCase,
@ -15,7 +15,10 @@ import {
buildListCustomersUseCase,
buildUpdateCustomerUseCase,
} 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 { buildCustomerRepository } from "./customer-repositories.di";
@ -39,21 +42,33 @@ export type CustomersInternalDeps = {
export function buildCustomersDependencies(params: ModuleParams): CustomersInternalDeps {
const { database } = params;
// Infrastructure
const catalogs = resolveCustomerCatalogsDeps(params);
const transactionManager = buildTransactionManager(database);
const catalogs = buildCatalogs();
const persistenceMappers = buildCustomerPersistenceMappers(catalogs);
const persistenceMappers = buildCustomerPersistenceMappers();
const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
//const numberService = buildCustomerNumberGenerator();
// Application helpers
const inputMappers = buildCustomerInputMappers(catalogs);
const inputMappers = buildCustomerInputMappers();
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 creator = buildCustomerCreator({ repository });
const updater = buildCustomerUpdater({ repository });
const snapshotBuilders = buildCustomerSnapshotBuilders();
//const documentGeneratorPipeline = buildCustomerDocumentService(params);
// Internal use cases (factories)
@ -69,6 +84,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
getCustomerById: () =>
buildGetCustomerByIdUseCase({
finder,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
@ -77,6 +93,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
buildCreateCustomerUseCase({
creator,
dtoMapper: inputMappers.createInputMapper,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
@ -85,6 +102,7 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
buildUpdateCustomerUseCase({
updater,
dtoMapper: inputMappers.updateInputMapper,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),

View File

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

View File

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

View File

@ -4,11 +4,10 @@ export const CreateCustomerRequestSchema = z.object({
id: z.string().nonempty(),
reference: z.string().optional(),
is_company: z.string().toLowerCase().default("true"),
is_company: z.boolean().default(true),
name: z.string(),
trade_name: z.string().optional(),
tin: z.string().optional(),
default_taxes: z.string().default(""),
street: z.string().optional(),
street2: z.string().optional(),
@ -32,8 +31,8 @@ export const CreateCustomerRequestSchema = z.object({
payment_method_id: z.uuid().nullable().optional(),
payment_term_id: z.uuid().nullable().optional(),
tax_regime_code: z.string().nullable().optional(),
uses_equivalence_surcharge: z.boolean().optional(),
uses_retention: z.boolean().optional(),
uses_equivalence_surcharge: z.boolean().default(false),
uses_retention: z.boolean().default(false),
language_code: z.string().toLowerCase().default("es"),
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";
export const useCustomerCreatePageController = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo") ?? "/customers";
const createCtrl = useCustomerCreateController({
onCreated: (customer) => navigate(`/customers/${customer.id}`),
@ -11,5 +14,6 @@ export const useCustomerCreatePageController = () => {
return {
createCtrl,
returnTo,
};
};

View File

@ -6,6 +6,7 @@ import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../i18n";
import type { CreateCustomerParams, Customer } from "../../shared";
import { useCustomerCreateMutation } from "../../shared/hooks/use-customer-create-mutation";
import { mapCustomerToCustomerCreateForm } from "../adapters";
import {
type CustomerCreateForm,
CustomerCreateFormSchema,
@ -47,13 +48,19 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
};
const submitHandler = form.handleSubmit(
async (formData) => {
async (formData: CustomerCreateForm) => {
const params: CreateCustomerParams = buildCreateCustomerParams(formData);
try {
// Enviamos cambios al servidor
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) {
showSuccessToast(
t("create.feedback.success.title"),

View File

@ -19,7 +19,6 @@ export interface CustomerCreateForm {
name: string;
tradeName: string;
tin: string;
defaultTaxes: string[];
street: 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";
/**
@ -17,32 +27,31 @@ import { z } from "zod/v4";
export const CustomerCreateFormSchema = z.object({
reference: z.string(),
isCompany: z.boolean(),
name: z.string().min(1, "El nombre es obligatorio"),
tradeName: z.string(),
tin: z.string(),
defaultTaxes: z.array(z.string()),
tin: TinSchema.or(z.literal("")),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postalCode: z.string(),
country: z.string().min(1, "El país es obligatorio"),
postalCode: PostalCodeSchema.or(z.literal("")),
country: CountryCodeSchema.or(z.literal("")),
primaryEmail: z.email("Email inválido"),
secondaryEmail: z.email("Email inválido"),
primaryEmail: z.email("Email inválido").or(z.literal("")),
secondaryEmail: z.email("Email inválido").or(z.literal("")),
primaryPhone: z.string(),
secondaryPhone: z.string(),
primaryMobile: z.string(),
secondaryMobile: z.string(),
primaryPhone: LandPhoneSchema.or(z.literal("")),
secondaryPhone: LandPhoneSchema.or(z.literal("")),
primaryMobile: MobilePhoneSchema.or(z.literal("")),
secondaryMobile: MobilePhoneSchema.or(z.literal("")),
fax: z.string(),
website: z.url("URL inválida"),
fax: LandPhoneSchema.or(z.literal("")),
website: WebsiteSchema.or(z.literal("")),
legalRecord: z.string(),
legalRecord: z.string().or(z.literal("")),
languageCode: z.string().min(1, "El idioma es obligatorio"),
currencyCode: z.string().min(1, "La moneda es obligatoria"),
languageCode: LanguageCodeSchema,
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 { useTranslation } from "../../../i18n";
import type { CustomerUpdateForm } from "../../entities";
import { CustomerTaxesMultiSelect } from "./customer-taxes-multi-select";
import type { CustomerCreateForm } from "../../entities";
interface CustomerBasicInfoFieldsProps extends React.ComponentProps<typeof FieldSet> {}
export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicInfoFieldsProps) => {
const { t } = useTranslation();
const { control, setFocus } = useFormContext<CustomerUpdateForm>();
const { control, setFocus } = useFormContext<CustomerCreateForm>();
useEffect(() => {
setFocus("name");
@ -60,17 +58,17 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
disabled={field.disabled}
name={field.name}
onValueChange={(value) => {
field.onChange(value === "true");
field.onChange(value === "company");
}}
required
value={field.value ? "true" : "false"}
value={field.value ? "company" : "individual"}
>
<Field data-invalid={fieldState.invalid} orientation="horizontal">
<FieldContent>
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="customer-type-company"
value="true"
value="company"
/>
<FieldLabel
className="cursor-pointer text-sm font-medium leading-none"
@ -86,7 +84,7 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
<RadioGroupItem
aria-invalid={fieldState.invalid}
id="customer-type-individual"
value="false"
value="individual"
/>
<FieldLabel
className="cursor-pointer text-sm font-medium leading-none"
@ -111,7 +109,6 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
label={t("fields.tin.label")}
name="tin"
placeholder={t("fields.tin.placeholder")}
required
/>
<TextField
@ -130,32 +127,6 @@ export const CustomerBasicInfoFields = ({ className, ...props }: CustomerBasicIn
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
className="lg:col-span-full"
description={t("fields.legal_record.description")}

View File

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

View File

@ -1,61 +1,64 @@
import { ErrorAlert, PageHeader } from "@erp/core/components";
import { UnsavedChangesProvider } from "@erp/core/hooks";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { ErrorAlert } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useCustomerCreatePageController } from "../../controllers";
import { CustomerCreateHeader } from "../blocks";
import { CustomerCreateEditorForm } from "../editor";
export const CustomerCreatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { createCtrl } = useCustomerCreatePageController();
const { createCtrl, returnTo } = useCustomerCreatePageController();
const { form, formId, onSubmit, resetForm, isCreating, isCreateError, createError } = createCtrl;
const { navigateBack } = useReturnToNavigation({
fallbackPath: returnTo,
});
const handleCancel = () => navigateBack();
return (
<div className="fixed inset-0 flex flex-col overflow-hidden">
<FormProvider {...form}>
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader className="mx-auto max-w-7xl space-y-4">
<PageHeader
description={t("create.description")}
onBackClick={() => navigate("/customers/list")}
rightSlot={
/*
<FormCommitButtonGroup
cancel={{
to: "/customers/list",
}}
disabled={createCtrl.isCreating}
isLoading={createCtrl.isCreating}
onReset={createCtrl.form.formState.isDirty ? createCtrl.resetForm : undefined}
submit={{
formId: createCtrl.formId,
}}
/>
*/ <></>
}
title={t("create.title")}
<FormProvider {...createCtrl.form}>
<UnsavedChangesProvider isDirty={createCtrl.form.formState.isDirty}>
<CustomerCreateHeader
formId={createCtrl.formId}
hasChanges={createCtrl.form.formState.isDirty}
isSaving={createCtrl.form.formState.isSubmitting}
labels={{
title: "Nuevo cliente",
back: "Volver",
modified: "",
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}
/>
{isCreateError && (
<ErrorAlert
message={(createError as Error)?.message ?? t("create.errors.message")}
title={t("create.errors.title")}
/>
</AppHeader>
<AppContent className="mx-auto max-w-7xl space-y-4">
{isCreateError && (
<ErrorAlert
message={(createError as Error)?.message ?? t("create.errors.message")}
title={t("create.errors.title")}
/>
)}
)}
<div className="flex-1 overflow-y-auto">
<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}
onSubmit={onSubmit}
/>
</AppContent>
</div>
</UnsavedChangesProvider>
</FormProvider>
</div>

View File

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

View File

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

View File

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

View File

@ -12,11 +12,6 @@ import type { Customer } from "../entities";
export const GetCustomerByIdAdapter = {
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 {
id: dto.id,
companyId: dto.company_id,
@ -49,7 +44,14 @@ export const GetCustomerByIdAdapter = {
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,
languageCode: dto.language_code,
currencyCode: dto.currency_code,

View File

@ -38,7 +38,26 @@ export interface Customer {
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;
currencyCode: string;

View File

@ -13,3 +13,17 @@ export function toValidationErrors(error: ZodError<unknown>) {
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";
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,34 +13,46 @@ import type { CustomerUpdateForm } from "../entities"; /**
*/
import { buildCustomerUpdateDefault } from "../utils";
export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpdateForm => ({
reference: customer.reference ?? "",
isCompany: customer.isCompany ?? buildCustomerUpdateDefault().isCompany,
name: customer.name,
tradeName: customer.tradeName ?? "",
tin: customer.tin ?? "",
export const mapCustomerToCustomerUpdateForm = (customer: Customer): CustomerUpdateForm => {
console.log(customer);
defaultTaxes: customer.defaultTaxes ?? [],
return {
reference: customer.reference ?? "",
isCompany: customer.isCompany ?? buildCustomerUpdateDefault().isCompany,
name: customer.name,
tradeName: customer.tradeName ?? "",
tin: customer.tin ?? "",
street: customer.address.street ?? "",
street2: customer.address.street2 ?? "",
city: customer.address.city ?? "",
province: customer.address.province ?? "",
postalCode: customer.address.postalCode ?? "",
country: customer.address.country ?? buildCustomerUpdateDefault().country,
street: customer.address.street ?? "",
street2: customer.address.street2 ?? "",
city: customer.address.city ?? "",
province: customer.address.province ?? "",
postalCode: customer.address.postalCode ?? "",
country: customer.address.country ?? buildCustomerUpdateDefault().country,
primaryEmail: customer.contact.primaryEmail ?? "",
secondaryEmail: customer.contact.secondaryEmail ?? "",
primaryPhone: customer.contact.primaryPhone ?? "",
secondaryPhone: customer.contact.secondaryPhone ?? "",
primaryMobile: customer.contact.primaryMobile ?? "",
secondaryMobile: customer.contact.secondaryMobile ?? "",
primaryEmail: customer.contact.primaryEmail ?? "",
secondaryEmail: customer.contact.secondaryEmail ?? "",
primaryPhone: customer.contact.primaryPhone ?? "",
secondaryPhone: customer.contact.secondaryPhone ?? "",
primaryMobile: customer.contact.primaryMobile ?? "",
secondaryMobile: customer.contact.secondaryMobile ?? "",
fax: customer.contact.fax ?? "",
website: customer.contact.website ?? "",
fax: customer.contact.fax ?? "",
website: customer.contact.website ?? "",
legalRecord: customer.legalRecord ?? "",
legalRecord: customer.legalRecord ?? "",
languageCode: customer.languageCode ?? buildCustomerUpdateDefault().languageCode,
currencyCode: customer.currencyCode ?? buildCustomerUpdateDefault().currencyCode,
});
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,
currencyCode: customer.currencyCode ?? buildCustomerUpdateDefault().currencyCode,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,23 @@
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 { CustomerAddressEditor } from "./customer-address-editor";
import { CustomerBasicInfoEditor } from "./customer-basic-info-editor";
import { CustomerContactEditor } from "./customer-contact-fields";
import { CustomerFiscalEditor } from "./customer-fiscal-editor";
type CustomerUpdateEditorFormProps = {
formId: string;
isSubmitting: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
onReset: () => void;
fiscalCtrl: UseCustomerUpdateFiscalControllerResult;
className?: string;
};
export const CustomerUpdateEditorForm = ({
@ -17,19 +25,30 @@ export const CustomerUpdateEditorForm = ({
isSubmitting,
onSubmit,
onReset,
fiscalCtrl,
className,
}: CustomerUpdateEditorFormProps) => {
return (
<form
className="space-y-6"
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
onSubmit={onSubmit}
>
<CustomerBasicInfoEditor disabled={isSubmitting} />
<CustomerAddressEditor />
<CustomerContactEditor />
<CustomerAdditionalConfigEditor />
</form>
<div className={cn("p-6", className)}>
<form
className="space-y-6 2xl:space-y-12"
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
onSubmit={onSubmit}
>
<CustomerBasicInfoEditor disabled={isSubmitting} />
<CustomerAddressEditor />
<CustomerFiscalEditor
paymentMethodOptions={fiscalCtrl.paymentMethodOptions}
paymentTermOptions={fiscalCtrl.paymentTermOptions}
taxRegimeOptions={fiscalCtrl.taxRegimeOptions}
/>
<CustomerContactEditor />
<CustomerAdditionalConfigEditor />
</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 { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Spinner } from "@repo/shadcn-ui/components";
import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
@ -81,16 +80,25 @@ export const CustomerUpdatePage = () => {
//onExportPdf={handleExportPdf}
/>
{updateCtrl.isLoading && <Spinner />}
{!updateCtrl.isLoading && (
{updateCtrl.isUpdateError && (
<ErrorAlert
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
className="max-w-5xl mx-auto"
fiscalCtrl={updateCtrl.fiscalCtrl}
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
/>
)}
</div>
</UnsavedChangesProvider>
</FormProvider>
</div>

View File

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

View File

@ -47,10 +47,6 @@ export const buildCustomerUpdatePatch = (
patch.tin = toNullableText(formData.tin);
}
if (dirtyFields.defaultTaxes) {
patch.defaultTaxes = formData.defaultTaxes;
}
if (dirtyFields.street) {
patch.street = toNullableText(formData.street);
}
@ -111,6 +107,26 @@ export const buildCustomerUpdatePatch = (
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) {
patch.languageCode = toRequiredText(formData.languageCode);
}
@ -119,6 +135,8 @@ export const buildCustomerUpdatePatch = (
patch.currencyCode = toRequiredText(formData.currencyCode);
}
console.log(dirtyFields, patch);
return patch;
};

View File

@ -46,10 +46,6 @@ export const buildUpdateCustomerByIdParams = (
data.tin = patchData.tin;
}
if (patchData.defaultTaxes !== undefined) {
data.default_taxes = patchData.defaultTaxes?.toString();
}
if (patchData.legalRecord !== undefined) {
data.legal_record = patchData.legalRecord;
}
@ -62,6 +58,27 @@ export const buildUpdateCustomerByIdParams = (
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 ---
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";
export function useCustomerViewPageController() {
const viewCtrl = useCustomerViewController();
const customerId = useUrlParamId();
const [searchParams] = useSearchParams();
const viewCtrl = useCustomerViewController(customerId);
const returnTo = searchParams.get("returnTo") ?? "/customers";
return {
viewCtrl,
returnTo,
};
}

View File

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

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 { useUrlParamId } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import { ErrorAlert, NotFoundCard } from "@erp/core/components";
import { useReturnToNavigation } from "@erp/core/hooks";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import {
Banknote,
EditIcon,
FileText,
Globe,
Languages,
Mail,
MapPin,
MoreVertical,
Phone,
Smartphone,
} from "lucide-react";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { CustomerEditorSkeleton } from "../../../update/ui/components";
import { useCustomerViewPageController } from "../../controllers";
import { CustomerViewHeader } from "../blocks";
export const CustomerViewPage = () => {
const initialCustomerId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
const {
viewCtrl: { setCustomerId, customerId, data: customerData, isError, error, isLoading },
} = useCustomerViewPageController();
const { viewCtrl, returnTo } = useCustomerViewPageController();
useEffect(() => {
if (initialCustomerId && customerId !== initialCustomerId) {
setCustomerId(initialCustomerId);
}
}, [initialCustomerId, customerId, setCustomerId]);
const { navigateBack } = useReturnToNavigation({
fallbackPath: returnTo,
});
if (isLoading) {
return <CustomerEditorSkeleton />;
const handleCancel = () => navigateBack();
if (viewCtrl.isLoading) {
return <CustomerEditorSkeleton />; /*<CustomerViewSkeleton />;*/
}
if (isError) {
if (viewCtrl.isLoadError) {
return (
<>
<AppContent>
<ErrorAlert
message={
(error as Error)?.message ??
t("view.errors.load_message")
}
message={(viewCtrl.loadError as Error)?.message ?? t("view.errors.load_message")}
title={t("view.errors.load_title")}
/>
@ -67,332 +50,314 @@ export const CustomerViewPage = () => {
);
}
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>
if (!viewCtrl.customerData)
return (
<AppContent>
{/* Main Content Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* Información Básica */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="size-5 text-primary" />
{t("view.sections.basic_info")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.name")}
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.reference")}
</dt>
<dd className="mt-1 font-mono text-base text-foreground">
{customerData?.reference}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.legal_record")}
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.legalRecord}</dd>
</div>
<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>
</div>
</CardContent>
</Card>
<NotFoundCard
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>
);
{/* Dirección */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<MapPin className="size-5 text-primary" />
{t("view.sections.address")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
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 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="size-5 text-primary" />
{t("view.sections.basic_info")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">{t("view.fields.name")}</dt>
<dd className="mt-1 text-base text-foreground">{viewCtrl.customerData?.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.reference")}
</dt>
<dd className="mt-1 font-mono text-base text-foreground">
{viewCtrl.customerData?.reference}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.legal_record")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.legalRecord}
</dd>
</div>
</CardContent>
</Card>
{/* Dirección */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<MapPin className="size-5 text-primary" />
{t("view.sections.address")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.street")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.street}
{viewCtrl.customerData?.street2 && (
<>
<br />
{viewCtrl.customerData?.street2}
</>
)}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.street")}
{t("view.fields.city")}
</dt>
<dd className="mt-1 text-base text-foreground">{viewCtrl.customerData?.city}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.postal_code")}
</dt>
<dd className="mt-1 text-base text-foreground">
{customerData?.street}
{customerData?.street2 && (
<>
<br />
{customerData?.street2}
</>
)}
{viewCtrl.customerData?.postalCode}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.city")}
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.city}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.postal_code")}
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.postalCode}</dd>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.province")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.province}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.province")}
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.province}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.country")}
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.country}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.country")}
</dt>
<dd className="mt-1 text-base text-foreground">{viewCtrl.customerData?.country}</dd>
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
{/* Información de Contacto */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Mail className="size-5 text-primary" />
{t("view.sections.contact_info")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
{/* Contacto Principal */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">
{t("view.sections.primary_contact")}
</h3>
{customerData?.primaryEmail && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.email")}
</dt>
<dd className="mt-1 text-base text-foreground">
{customerData?.primaryEmail}
</dd>
</div>
{/* Información de Contacto */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Mail className="size-5 text-primary" />
{t("view.sections.contact_info")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
{/* Contacto Principal */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">
{t("view.sections.primary_contact")}
</h3>
{viewCtrl.customerData?.primaryEmail && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.email")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.primaryEmail}
</dd>
</div>
)}
{customerData?.primaryMobile && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.mobile")}
</dt>
<dd className="mt-1 text-base text-foreground">
{customerData?.primaryMobile}
</dd>
</div>
</div>
)}
{viewCtrl.customerData?.primaryMobile && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.mobile")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.primaryMobile}
</dd>
</div>
)}
{customerData?.primaryPhone && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.phone")}
</dt>
<dd className="mt-1 text-base text-foreground">
{customerData?.primaryPhone}
</dd>
</div>
</div>
)}
</div>
{/* Contacto Secundario */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">
{t("view.sections.secondary_contact")}
</h3>
{customerData?.secondaryEmail && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.email")}
</dt>
<dd className="mt-1 text-base text-foreground">
{customerData?.secondaryEmail}
</dd>
</div>
</div>
)}
{customerData?.secondaryMobile && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.mobile")}
</dt>
<dd className="mt-1 text-base text-foreground">
{customerData?.secondaryMobile}
</dd>
</div>
</div>
)}
{customerData?.secondaryPhone && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.phone")}
</dt>
<dd className="mt-1 text-base text-foreground">
{customerData?.secondaryPhone}
</dd>
</div>
</div>
)}
</div>
{/* Otros Contactos */}
{(customerData?.website || customerData?.fax) && (
<div className="space-y-4 md:col-span-2">
<h3 className="font-semibold text-foreground">
{t("view.sections.other_contacts")}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{customerData?.website && (
<div className="flex items-start gap-3">
<Globe className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.website")}
</dt>
<dd className="mt-1 text-base text-primary hover:underline">
<a
href={customerData?.website}
rel="noopener noreferrer"
target="_blank"
>
{customerData?.website}
</a>
</dd>
</div>
</div>
)}
{customerData?.fax && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.fax")}
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.fax}</dd>
</div>
</div>
)}
</div>
)}
{viewCtrl.customerData?.primaryPhone && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.phone")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.primaryPhone}
</dd>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Preferencias */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Languages className="size-5 text-primary" />
{t("view.sections.preferences")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div className="flex items-start gap-3">
<Languages className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.language_code")}
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.languageCode}</dd>
{/* Contacto Secundario */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">
{t("view.sections.secondary_contact")}
</h3>
{viewCtrl.customerData?.secondaryEmail && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.email")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.secondaryEmail}
</dd>
</div>
</div>
)}
{viewCtrl.customerData?.secondaryMobile && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.mobile")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.secondaryMobile}
</dd>
</div>
</div>
)}
{viewCtrl.customerData?.secondaryPhone && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.phone")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.secondaryPhone}
</dd>
</div>
</div>
)}
</div>
{/* Otros Contactos */}
{(viewCtrl.customerData?.website || viewCtrl.customerData?.fax) && (
<div className="space-y-4 md:col-span-2">
<h3 className="font-semibold text-foreground">
{t("view.sections.other_contacts")}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{viewCtrl.customerData?.website && (
<div className="flex items-start gap-3">
<Globe className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.website")}
</dt>
<dd className="mt-1 text-base text-primary hover:underline">
<a
href={viewCtrl.customerData?.website}
rel="noopener noreferrer"
target="_blank"
>
{viewCtrl.customerData?.website}
</a>
</dd>
</div>
</div>
)}
{viewCtrl.customerData?.fax && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.fax")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.fax}
</dd>
</div>
</div>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Banknote className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.currency_code")}
</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.currencyCode}</dd>
</div>
)}
</div>
</CardContent>
</Card>
{/* Preferencias */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Languages className="size-5 text-primary" />
{t("view.sections.preferences")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div className="flex items-start gap-3">
<Languages className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.language_code")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.languageCode}
</dd>
</div>
</div>
</CardContent>
</Card>
</div>
</AppContent>
</>
<div className="flex items-start gap-3">
<Banknote className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">
{t("view.fields.currency_code")}
</dt>
<dd className="mt-1 text-base text-foreground">
{viewCtrl.customerData?.currencyCode}
</dd>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
};

View File

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