This commit is contained in:
David Arranz 2026-06-15 20:27:11 +02:00
parent 6c11dd7027
commit f6f57359d5
52 changed files with 964 additions and 560 deletions

View File

@ -1,4 +1,4 @@
export * from "./mappers";
export * from "./models/";
export * from "./services";
export * from "./services/";

View File

@ -1,6 +1,7 @@
export * from "./di";
export * from "./mappers";
export * from "./models";
export * from "./public";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";

View File

@ -0,0 +1,3 @@
export * from "./mappers";
export * from "./models";
export * from "./services";

View File

@ -0,0 +1,3 @@
export * from "./payment-term-public-model.mapper";

View File

@ -0,0 +1,14 @@
import type { PaymentTerm } from "../../../domain";
import type { PaymentTermPublicModel } from "../models";
export class PaymentTermPublicModelMapper {
public toPublicModel(paymentTerm: PaymentTerm): PaymentTermPublicModel {
return {
id: paymentTerm.id,
companyId: paymentTerm.companyId,
name: paymentTerm.name.toPrimitive(),
description: paymentTerm.description.map((value) => value.toPrimitive()),
isActive: paymentTerm.isActive,
};
}
}

View File

@ -0,0 +1 @@
export * from "./payment-term-public.model";

View File

@ -0,0 +1,12 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
export interface PaymentTermPublicModel {
id: UniqueID;
companyId: UniqueID;
name: string;
description: Maybe<string>;
isActive: boolean;
}

View File

@ -0,0 +1 @@
export * from "./payment-term-public-finder";

View File

@ -0,0 +1,86 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { PaymentTermPublicModelMapper } from "../mappers";
import type { PaymentTermPublicModel } from "../models";
import type { IPaymentTermRepository } from "../repositories";
export interface FindPaymentTermByIdInCompanyParams {
companyId: UniqueID;
id: UniqueID;
transaction?: unknown;
}
export interface IPaymentTermPublicFinder {
existsByIdInCompany(params: FindPaymentTermByIdInCompanyParams): Promise<Result<boolean, Error>>;
getByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<PaymentTermPublicModel, Error>>;
findByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<Maybe<PaymentTermPublicModel>, Error>>;
}
export class PaymentTermPublicFinder implements IPaymentTermPublicFinder {
public constructor(
private readonly deps: {
repository: IPaymentTermRepository;
mapper: PaymentTermPublicModelMapper;
}
) {}
public async existsByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<boolean, Error>> {
const result = await this.deps.repository.existsByIdInCompany(
params.companyId,
params.id,
params.transaction
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(result.data);
}
public async getByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<PaymentTermPublicModel, Error>> {
const result = await this.deps.repository.getByIdInCompany(
params.companyId,
params.id,
params.transaction
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(this.deps.mapper.toPublicModel(result.data));
}
public async findByIdInCompany(
params: FindPaymentTermByIdInCompanyParams
): Promise<Result<Maybe<PaymentTermPublicModel>, Error>> {
const result = await this.deps.repository.findByIdInCompany(
params.companyId,
params.id,
params.transaction
);
if (result.isFailure) {
return Result.fail(result.error);
}
return Result.ok(
result.data.match(
(paymentTerm) => Maybe.some(this.deps.mapper.toPublicModel(paymentTerm)),
() => Maybe.none<PaymentTermPublicModel>()
)
);
}
}

View File

@ -1,8 +1,8 @@
import type { IPaymentTermCreator } from "./payment-term-creator.service";
import type { IPaymentTermDeleter } from "./payment-term-deleter.service";
import type { IPaymentTermFinder } from "./payment-term-finder.service";
import type { IPaymentTermStatusChanger } from "./payment-term-status-changer.service";
import type { IPaymentTermUpdater } from "./payment-term-updater.service";
import type { IPaymentTermCreator } from "../../services";
import type { IPaymentTermDeleter } from "../../services";
import type { IPaymentTermFinder } from "../../services";
import type { IPaymentTermStatusChanger } from "../../services";
import type { IPaymentTermUpdater } from "../../services";
export interface IPaymentTermPublicServices {
finder: IPaymentTermFinder;

View File

@ -1,6 +1,6 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { PaymentTerm } from "../../../domain";
import type { PaymentTermSummary } from "../models/payment-term-summary.model";
@ -23,6 +23,11 @@ export interface IPaymentTermRepository {
id: UniqueID,
transaction?: unknown
): Promise<Result<PaymentTerm, Error>>;
findByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<PaymentTerm>, Error>>;
findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,

View File

@ -1,6 +1,7 @@
export * from "../public/services/payment-term-public-services";
export * from "./payment-term-creator.service";
export * from "./payment-term-deleter.service";
export * from "./payment-term-finder.service";
export * from "./payment-term-public-services";
export * from "./payment-term-status-changer.service";
export * from "./payment-term-updater.service";

View File

@ -16,6 +16,7 @@ import {
} from "./infrastructure/di/catalogs.di";
export * from "./application/payment-methods/public";
export * from "./application/payment-terms/public";
export * from "./application/tax-definitions/public";
export * from "./application/tax-regimes/public";

View File

@ -191,9 +191,12 @@ export class SequelizePaymentMethodRepository
): Promise<Result<Collection<PaymentMethodSummary>, Error>> {
try {
const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, {
const criteriaQuery = criteriaConverter.convert(criteria, {
mappings: {
isActive: "is_active",
isActive: {
type: "root",
column: "is_active",
},
},
searchableFields: [],
sortableFields: ["name"],
@ -202,19 +205,19 @@ export class SequelizePaymentMethodRepository
strictMode: true, // fuerza error si ORDER BY no permitido
});
query.where = {
...query.where,
criteriaQuery.where = {
...criteriaQuery.where,
company_id: companyId.toString(),
deleted_at: null,
};
const [rows, count] = await Promise.all([
PaymentMethodModel.findAll({
...query,
...criteriaQuery,
transaction,
}),
PaymentMethodModel.count({
where: query.where,
where: criteriaQuery.where,
distinct: true, // evita duplicados por LEFT JOIN
transaction,
}),

View File

@ -6,7 +6,7 @@ import {
} from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import { type Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import type { PaymentTermSummary } from "../../../../../application/payment-terms/models/payment-term-summary.model";
@ -118,7 +118,10 @@ export class SequelizePaymentTermRepository
const query = criteriaConverter.convert(criteria, {
searchableFields: [],
mappings: {
isActive: "is_active",
isActive: {
type: "root",
column: "is_active",
},
},
sortableFields: ["name"],
enableFullText: true,
@ -151,6 +154,35 @@ export class SequelizePaymentTermRepository
}
}
async findByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: Transaction
): Promise<Result<Maybe<PaymentTerm>, Error>> {
try {
const row = await PaymentTermModel.findOne({
where: {
id: id.toString(),
company_id: companyId.toString(),
},
transaction,
});
if (!row) {
return Result.ok(Maybe.none<PaymentTerm>());
}
const mappedResult = this.domainMapper.mapToDomain(row);
if (mappedResult.isFailure) {
return Result.fail(mappedResult.error);
}
return Result.ok(Maybe.some(mappedResult.data));
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
async deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,

View File

@ -188,7 +188,10 @@ export class SequelizeTaxDefinitionRepository
const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, {
mappings: {
isActive: "is_active",
isActive: {
type: "root",
column: "is_active",
},
},
searchableFields: ["code", "name", "description", "invoice_note"],
sortableFields: ["code", "name"],

View File

@ -204,7 +204,10 @@ export class SequelizeTaxRegimeRepository
const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, {
mappings: {
isActive: "is_active",
isActive: {
type: "root",
column: "is_active",
},
},
searchableFields: [],
sortableFields: ["code"],

View File

@ -1,167 +0,0 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import { Op, type OrderItem, type WhereOptions, literal } from "sequelize";
// Campos físicos (DB) que permitimos filtrar/ordenar
const ALLOWED_FILTERS = {
id: "id",
customerId: "customer_id",
invoiceSeries: "invoice_series",
invoiceNumber: "invoice_number",
invoiceDate: "invoice_date",
status: "status",
currencyCode: "currency_code",
// Rango por total (en unidades menores)
totalAmountValue: "total_amount_value",
} as const;
const ALLOWED_SORT: Record<string, string | string[]> = {
// Sort "invoiceDate" realmente ordena por (invoice_date DESC, invoice_series ASC, invoice_number DESC, id DESC)
invoiceDate: ["invoice_date"],
invoiceNumber: ["invoice_number"],
invoiceSeries: ["invoice_series"],
status: ["status"],
createdAt: ["created_at"],
updatedAt: ["updated_at"],
};
// Proyección mínima para el listado (evita N+1 y payloads grandes)
export const DEFAULT_LIST_ATTRIBUTES = [
"id",
"company_id",
"customer_id",
"invoice_series",
"invoice_number",
"invoice_date",
"status",
"total_amount_value",
"total_amount_scale",
"currency_code",
// Agregamos itemsCount por subconsulta (no es columna real)
] as const;
type Sanitized = {
where: WhereOptions;
order: OrderItem[];
limit: number;
offset: number;
attributes: (string | any)[];
// keyset opcional
keyset?: {
after?: { invoiceDate: string; invoiceSeries: string; invoiceNumber: number; id: string };
};
};
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 25;
export function sanitizeListCriteria(criteria: Criteria): Sanitized {
const { filters = {}, sort = [], pagination = {}, search } = (criteria ?? {}) as any;
// LIMIT/OFFSET
const rawLimit = Number(pagination?.limit ?? DEFAULT_LIMIT);
const rawOffset = Number(pagination?.offset ?? 0);
const limit = Number.isFinite(rawLimit)
? Math.max(1, Math.min(MAX_LIMIT, rawLimit))
: DEFAULT_LIMIT;
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
// WHERE base siempre por seguridad a nivel superior (company en repo)
const where: WhereOptions = {};
// Búsqueda libre (q) → aplicar sobre campos concretos (series/number/status)
if (typeof search?.q === "string" && search.q.trim() !== "") {
const q = `%${search.q.trim()}%`;
Object.assign(where, {
[Op.or]: [
{ invoice_series: { [Op.like]: q } },
{ status: { [Op.like]: q } },
// Buscar por número como texto
literal(`CAST(invoice_number AS CHAR) LIKE ${escapeLikeLiteral(q)}`),
],
});
}
// Filtros permitidos
for (const [key, value] of Object.entries(filters)) {
const column = (ALLOWED_FILTERS as any)[key];
if (!column) {
throw forbiddenFilter(key);
}
if (value == null) continue;
// Operadores sencillos permitidos
if (typeof value === "object" && !Array.isArray(value)) {
const v = value as any;
const ops: any = {};
if (v.eq !== undefined) ops[Op.eq] = v.eq;
if (v.ne !== undefined) ops[Op.ne] = v.ne;
if (v.in !== undefined && Array.isArray(v.in)) ops[Op.in] = v.in;
if (v.like !== undefined) ops[Op.like] = `%${String(v.like)}%`;
if (v.gte !== undefined) ops[Op.gte] = v.gte;
if (v.lte !== undefined) ops[Op.lte] = v.lte;
if (Object.keys(ops).length === 0) continue;
(where as any)[column] = ops;
} else {
(where as any)[column] = value;
}
}
// ORDER (determinista)
const order: OrderItem[] = [];
const sortArray = Array.isArray(sort)
? sort
: String(sort || "")
.split(",")
.filter(Boolean);
if (sortArray.length === 0) {
// orden por defecto: invoice_date desc, invoice_series asc, invoice_number desc, id desc
order.push(
["invoice_date", "DESC"],
["invoice_series", "ASC"],
["invoice_number", "DESC"],
["id", "DESC"]
);
} else {
for (const part of sortArray) {
const desc = String(part).startsWith("-");
const key = desc ? String(part).slice(1) : String(part);
const allowed = ALLOWED_SORT[key];
if (!allowed) throw forbiddenSort(key);
const cols = Array.isArray(allowed) ? allowed : [allowed];
for (const c of cols) {
order.push([c, desc ? "DESC" : "ASC"]);
}
}
// tiebreaker final
order.push(["id", "DESC"]);
}
// Atributos (proyección)
const attributes: any[] = [...DEFAULT_LIST_ATTRIBUTES];
// itemsCount por subconsulta (evitamos include)
attributes.push([
literal(
"(SELECT COUNT(1) FROM customer_invoice_items it WHERE it.invoice_id = customer_invoices.id AND it.deleted_at IS NULL)"
),
"itemsCount",
]);
return { where, order, limit, offset, attributes };
}
// Helpers
function forbiddenFilter(field: string) {
const e = new Error(`Filter "${field}" is not allowed`);
(e as any).code = "FORBIDDEN_FILTER";
return e;
}
function forbiddenSort(field: string) {
const e = new Error(`Sort "${field}" is not allowed`);
(e as any).code = "FORBIDDEN_SORT";
return e;
}
function escapeLikeLiteral(v: string) {
// Simple escapado para usar en literal; asume conexión confiable. Para máxima seguridad usa replacements.
return `'${v.replace(/'/g, "''")}'`;
}

View File

@ -1,73 +0,0 @@
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { useTranslation } from "../../../../../i18n";
import type { ProformaList, ProformaListRow } from "../../../../shared";
interface ProformasGridProps {
data?: ProformaList;
loading: boolean;
fetching?: boolean;
columns: ColumnDef<ProformaListRow, unknown>[];
pageIndex: number;
pageSize: number;
onPageChange: (pageIndex: number) => void;
onPageSizeChange: (size: number) => void;
onRowClick?: (proformaId: string) => void;
}
export const ProformasGrid = ({
data,
loading,
fetching,
columns,
pageIndex,
pageSize,
onPageChange,
onPageSizeChange,
onRowClick,
}: ProformasGridProps) => {
const { t } = useTranslation();
const { items, totalItems } = data || { items: [], totalItems: 0 };
if (loading) {
return (
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: totalItems ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
);
}
return (
<div className="mx-auto space-y-4">
{/*
* ─── CAPA DE FADE ──────────────────────────────────────────────────────
* Div absolutamente posicionado sobre el área scrollable.
* - pointer-events: none → no bloquea interacciones.
* - linear-gradient → de transparente a bg-card (blanco/oscuro).
* - z-10 → queda por DEBAJO de la columna sticky (z-20).
* - Solo visible cuando aún hay contenido a la derecha.
* ────────────────────────────────────────────────────────────────────────
*/}
<DataTable
columns={columns}
data={items}
enablePagination
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={(row) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={totalItems}
/>
</div>
);
};

View File

@ -1,5 +1,3 @@
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components";
import {
CancelActionButton,

View File

@ -36,6 +36,7 @@
"dependencies": {
"@erp/auth": "workspace:*",
"@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@repo/i18next": "workspace:*",
"@repo/rdx-criteria": "workspace:*",

View File

@ -0,0 +1,20 @@
import type { Maybe } from "@repo/rdx-utils";
import type { Customer } from "../../domain";
import type { CustomerPaymentMethodReadModel } from "./customer-payment-method-read.model";
import type { CustomerPaymentTermReadModel } from "./customer-payment-term-read.model";
import type { CustomerTaxRegimeFullReadModel } from "./customer-tax-regime-full-read.model";
/**
* Modelo de un cliente con datos accesorios.
*
* Combina el agregado con datos auxiliares necesarios para
* construir la respuesta API generada por el snapshot builder.
*/
export interface CustomerFullReadModel {
customer: Customer;
paymentMethod: Maybe<CustomerPaymentMethodReadModel>;
paymentTerm: Maybe<CustomerPaymentTermReadModel>;
taxRegime: Maybe<CustomerTaxRegimeFullReadModel>;
}

View File

@ -0,0 +1,12 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
/**
* Datos del método de pago que completan al cliente
*/
export interface CustomerPaymentMethodReadModel {
id: UniqueID;
name: string;
description: Maybe<string>;
}

View File

@ -0,0 +1,12 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
/**
* Datos del plazo de pago que completan al cliente
*/
export interface CustomerPaymentTermReadModel {
id: UniqueID;
name: string;
description: Maybe<string>;
}

View File

@ -0,0 +1,8 @@
/**
* Datos del régimen de pago que completan al cliente
*/
export interface CustomerTaxRegimeFullReadModel {
code: string;
description: string;
}

View File

@ -1 +1,5 @@
export * from "./customer-full-read-model";
export * from "./customer-payment-method-read.model";
export * from "./customer-payment-term-read.model";
export * from "./customer-summary";
export * from "./customer-tax-regime-full-read.model";

View File

@ -1,2 +0,0 @@
export * from "./domain";
export * from "./queries";

View File

@ -1 +0,0 @@
export * from "./list-customers.presenter";

View File

@ -1,84 +0,0 @@
import type { CriteriaDTO } from "@erp/core";
import { Presenter } from "@erp/core/api";
import type { Criteria } from "@repo/rdx-criteria/server";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import type { Collection } from "@repo/rdx-utils";
import type { ListCustomersResponseDTO } from "../../../../common/dto";
import type { CustomerListDTO } from "../../../infrastructure/mappers";
export class ListCustomersPresenter extends Presenter {
protected _mapCustomer(customer: CustomerListDTO) {
const address = customer.address.toPrimitive();
return {
id: customer.id.toPrimitive(),
company_id: customer.companyId.toPrimitive(),
reference: maybeToEmptyString(customer.reference, (value) => value.toPrimitive()),
is_company: String(customer.isCompany),
name: customer.name.toPrimitive(),
trade_name: maybeToEmptyString(customer.tradeName, (value) => value.toPrimitive()),
tin: maybeToEmptyString(customer.tin, (value) => value.toPrimitive()),
street: maybeToEmptyString(address.street, (value) => value.toPrimitive()),
street2: maybeToEmptyString(address.street2, (value) => value.toPrimitive()),
city: maybeToEmptyString(address.city, (value) => value.toPrimitive()),
province: maybeToEmptyString(address.province, (value) => value.toPrimitive()),
postal_code: maybeToEmptyString(address.postalCode, (value) => value.toPrimitive()),
country: maybeToEmptyString(address.country, (value) => value.toPrimitive()),
email_primary: maybeToEmptyString(customer.emailPrimary, (value) => value.toPrimitive()),
email_secondary: maybeToEmptyString(customer.emailSecondary, (value) => value.toPrimitive()),
phone_primary: maybeToEmptyString(customer.phonePrimary, (value) => value.toPrimitive()),
phone_secondary: maybeToEmptyString(customer.phoneSecondary, (value) => value.toPrimitive()),
mobile_primary: maybeToEmptyString(customer.mobilePrimary, (value) => value.toPrimitive()),
mobile_secondary: maybeToEmptyString(customer.mobileSecondary, (value) =>
value.toPrimitive()
),
fax: maybeToEmptyString(customer.fax, (value) => value.toPrimitive()),
website: maybeToEmptyString(customer.website, (value) => value.toPrimitive()),
status: customer.status ? "active" : "inactive",
language_code: customer.languageCode.toPrimitive(),
currency_code: customer.currencyCode.toPrimitive(),
metadata: {
entity: "customer",
//created_at: customer.createdAt.toPrimitive(),
//updated_at: customer.updatedAt.toPrimitive()
},
};
}
toOutput(params: {
customers: Collection<CustomerListDTO>;
criteria: Criteria;
}): ListCustomersResponseDTO {
const { customers, criteria } = params;
const items = customers.map((customer) => this._mapCustomer(customer));
const totalItems = customers.total();
return {
page: criteria.pageNumber,
per_page: criteria.pageSize,
total_pages: Math.ceil(totalItems / criteria.pageSize),
total_items: totalItems,
items: items,
metadata: {
entity: "customers",
criteria: criteria.toJSON() as CriteriaDTO,
//links: {
// self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`,
// first: `/api/customer-invoices?page=1&per_page=${criteria.pageSize}`,
// last: `/api/customer-invoices?page=${Math.ceil(totalItems / criteria.pageSize)}&per_page=${criteria.pageSize}`,
//},
},
};
}
}

View File

@ -0,0 +1,196 @@
import type {
IPaymentMethodPublicFinder,
IPaymentTermPublicFinder,
ITaxRegimePublicFinder,
} from "@erp/catalogs/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Customer } from "../../../domain";
import type {
CustomerFullReadModel,
CustomerPaymentMethodReadModel,
CustomerTaxRegimeFullReadModel,
} from "../../models";
export interface ICustomerFullReadModelAssembler {
assemble(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<CustomerFullReadModel, Error>>;
}
/**
* Enriquece un cliente recuperado con
* datos accesorios recuperados del catálogo.
*
* No modifica el agregado. Su responsabilidad es
* preparar el modelo completo que recibirá el
* snapshot builder después.
*/
export class CustomerFullReadModelAssembler implements ICustomerFullReadModelAssembler {
public constructor(
private readonly deps: {
paymentMethodFinder: IPaymentMethodPublicFinder;
paymentTermFinder: IPaymentTermPublicFinder;
taxRegimeFinder: ITaxRegimePublicFinder;
}
) {}
public async assemble(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<CustomerFullReadModel, Error>> {
const paymentMethod = await this.resolvePaymentMethod(params);
if (paymentMethod.isFailure) {
return Result.fail(paymentMethod.error);
}
const paymentTerm = await this.resolvePaymentTerm(params);
if (paymentTerm.isFailure) {
return Result.fail(paymentTerm.error);
}
const taxRegime = await this.resolveTaxRegime(params);
if (taxRegime.isFailure) {
return Result.fail(taxRegime.error);
}
return Result.ok({
customer: params.customer,
paymentMethod: paymentMethod.data,
paymentTerm: paymentTerm.data,
taxRegime: taxRegime.data,
});
}
private async resolvePaymentMethod(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<Maybe<CustomerPaymentMethodReadModel>, Error>> {
if (params.customer.paymentMethodId.isNone()) {
return Result.ok(Maybe.none());
}
const paymentMethodId = params.customer.paymentMethodId.unwrap();
const result = await this.deps.paymentMethodFinder.findByIdInCompany({
companyId: params.companyId,
id: paymentMethodId,
transaction: params.transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.ok(
Maybe.some({
id: paymentMethodId,
name: "",
description: Maybe.none(),
})
);
}
const paymentMethod = result.data.unwrap();
return Result.ok(
Maybe.some({
id: paymentMethod.id,
name: paymentMethod.name.toString(),
description: paymentMethod.description ?? null,
})
);
}
private async resolvePaymentTerm(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<Maybe<CustomerPaymentMethodReadModel>, Error>> {
if (params.customer.paymentTermId.isNone()) {
return Result.ok(Maybe.none());
}
const paymentTermId = params.customer.paymentTermId.unwrap();
const result = await this.deps.paymentTermFinder.findByIdInCompany({
companyId: params.companyId,
id: paymentTermId,
transaction: params.transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.ok(
Maybe.some({
id: paymentTermId,
name: "",
description: Maybe.none(),
})
);
}
const paymentTerm = result.data.unwrap();
return Result.ok(
Maybe.some({
id: paymentTerm.id,
name: paymentTerm.name.toString(),
description: paymentTerm.description ?? null,
})
);
}
private async resolveTaxRegime(params: {
companyId: UniqueID;
customer: Customer;
transaction?: unknown;
}): Promise<Result<Maybe<CustomerTaxRegimeFullReadModel>, Error>> {
if (params.customer.taxRegimeCode.isNone()) {
return Result.ok(Maybe.none());
}
const taxRegimeCode = params.customer.taxRegimeCode.unwrap();
const result = await this.deps.taxRegimeFinder.findByCodeInCompany({
companyId: params.companyId,
code: taxRegimeCode,
transaction: params.transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.ok(
Maybe.some({
code: taxRegimeCode,
description: "",
})
);
}
const taxRegime = result.data.unwrap();
return Result.ok(
Maybe.some({
code: taxRegime.code,
description: taxRegime.description,
})
);
}
}

View File

@ -0,0 +1 @@
export * from "./customer-full-read-model.assembler";

View File

@ -1,14 +1,17 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { toNullable } from "@repo/rdx-ddd";
import { maybeToEmptyString, maybeToNullable, toNullable } from "@repo/rdx-ddd";
import type { GetCustomerByIdResponseDTO } from "../../../../common";
import type { Customer } from "../../../domain";
import type { CustomerFullReadModel } from "../../models";
export type CustomerFullSnapshot = GetCustomerByIdResponseDTO;
export interface ICustomerFullSnapshotBuilder
extends ISnapshotBuilder<Customer, GetCustomerByIdResponseDTO> {}
extends ISnapshotBuilder<CustomerFullReadModel, CustomerFullSnapshot> {}
export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder {
toOutput(customer: Customer): GetCustomerByIdResponseDTO {
toOutput(source: CustomerFullReadModel): CustomerFullSnapshot {
const { customer, paymentMethod, taxRegime, paymentTerm } = source;
const address = customer.address.toPrimitive();
return {
@ -47,7 +50,24 @@ export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder
legal_record: toNullable(customer.legalRecord, (value) => value.toPrimitive()),
default_taxes: customer.defaultTaxes.toKey(),
payment_method: maybeToNullable(paymentMethod, (paymentMethod) => ({
id: paymentMethod.id.toString(),
name: paymentMethod.name,
description: maybeToEmptyString(paymentMethod.description),
})),
payment_term: maybeToNullable(paymentTerm, (paymentTerm) => ({
id: paymentTerm.id.toString(),
name: paymentTerm.name,
description: maybeToEmptyString(paymentTerm.description),
})),
tax_regime: maybeToNullable(taxRegime, (taxRegime) => taxRegime),
fiscal_defaults: {
uses_equivalence_surcharge: customer.usesEquivalenceSurcharge,
uses_retention: customer.usesRetention,
},
language_code: customer.languageCode.toPrimitive(),
currency_code: customer.currencyCode.toPrimitive(),

View File

@ -3,6 +3,7 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { ICustomerFinder } from "../services";
import type { ICustomerFullReadModelAssembler } from "../services/assemblers";
import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders";
type GetCustomerUseCaseInput = {
@ -11,25 +12,29 @@ type GetCustomerUseCaseInput = {
};
export class GetCustomerByIdUseCase {
constructor(
private readonly finder: ICustomerFinder,
private readonly fullSnapshotBuilder: ICustomerFullSnapshotBuilder,
private readonly transactionManager: ITransactionManager
public constructor(
private readonly deps: {
finder: ICustomerFinder;
fullReadModelAssembler: ICustomerFullReadModelAssembler;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}
) {}
public execute(params: GetCustomerUseCaseInput) {
const { customer_id, companyId } = params;
const idOrError = UniqueID.create(customer_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const customerId = idOrError.data;
return this.transactionManager.complete(async (transaction) => {
return this.deps.transactionManager.complete(async (transaction) => {
try {
const customerResult = await this.finder.findCustomerById(
const customerResult = await this.deps.finder.findCustomerById(
companyId,
customerId,
transaction
@ -39,7 +44,17 @@ export class GetCustomerByIdUseCase {
return Result.fail(customerResult.error);
}
const fullSnapshot = this.fullSnapshotBuilder.toOutput(customerResult.data);
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId,
customer: customerResult.data,
transaction,
});
if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
const fullSnapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data);
return Result.ok(fullSnapshot);
} catch (error: unknown) {

View File

@ -15,7 +15,7 @@ import {
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { type CustomerStatus, CustomerTaxes } from "../value-objects";
import type { CustomerStatus } from "../value-objects";
export interface ICustomerCreateProps {
companyId: UniqueID;
@ -43,7 +43,11 @@ export interface ICustomerCreateProps {
legalRecord: Maybe<TextValue>;
defaultTaxes: CustomerTaxes;
paymentMethodId: Maybe<UniqueID>;
paymentTermId: Maybe<UniqueID>;
taxRegimeCode: Maybe<string>;
usesEquivalenceSurcharge: boolean;
usesRetention: boolean;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
@ -60,54 +64,50 @@ export interface ICustomer {
// comportamiento
update(partialCustomer: CustomerPatchProps): Result<void, Error>;
// propiedades (getters)
readonly isIndividual: boolean;
readonly isCompany: boolean;
readonly isActive: boolean;
isIndividual: boolean;
isCompany: boolean;
isActive: boolean;
readonly companyId: UniqueID;
companyId: UniqueID;
readonly reference: Maybe<Name>;
readonly name: Name;
readonly tradeName: Maybe<Name>;
readonly tin: Maybe<TINNumber>;
reference: Maybe<Name>;
name: Name;
tradeName: Maybe<Name>;
tin: Maybe<TINNumber>;
readonly address: PostalAddress;
address: PostalAddress;
readonly emailPrimary: Maybe<EmailAddress>;
readonly emailSecondary: Maybe<EmailAddress>;
emailPrimary: Maybe<EmailAddress>;
emailSecondary: Maybe<EmailAddress>;
readonly phonePrimary: Maybe<PhoneNumber>;
readonly phoneSecondary: Maybe<PhoneNumber>;
readonly mobilePrimary: Maybe<PhoneNumber>;
readonly mobileSecondary: Maybe<PhoneNumber>;
phonePrimary: Maybe<PhoneNumber>;
phoneSecondary: Maybe<PhoneNumber>;
mobilePrimary: Maybe<PhoneNumber>;
mobileSecondary: Maybe<PhoneNumber>;
readonly fax: Maybe<PhoneNumber>;
readonly website: Maybe<URLAddress>;
fax: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
readonly legalRecord: Maybe<TextValue>;
legalRecord: Maybe<TextValue>;
readonly defaultTaxes: CustomerTaxes;
paymentMethodId: Maybe<UniqueID>;
paymentTermId: Maybe<UniqueID>;
taxRegimeCode: Maybe<string>;
usesEquivalenceSurcharge: boolean;
usesRetention: boolean;
readonly languageCode: LanguageCode;
readonly currencyCode: CurrencyCode;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
}
export type CustomerInternalProps = Omit<ICustomerCreateProps, "address" | "defaultTaxes">;
export type CustomerInternalProps = Omit<ICustomerCreateProps, "address">;
export class Customer extends AggregateRoot<CustomerInternalProps> implements ICustomer {
private _address: PostalAddress;
private _defaultTaxes: CustomerTaxes;
protected constructor(
props: CustomerInternalProps,
address: PostalAddress,
defaultTaxes: CustomerTaxes,
id?: UniqueID
) {
protected constructor(props: CustomerInternalProps, address: PostalAddress, id?: UniqueID) {
super(props, id);
this._address = address;
this._defaultTaxes = defaultTaxes;
}
static create(props: ICustomerCreateProps, id?: UniqueID): Result<Customer, Error> {
@ -117,7 +117,7 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
return Result.fail(validationResult.error);
}
const { address, defaultTaxes, ...internalProps } = props;
const { address, ...internalProps } = props;
// Postal Address
const postalAddressResult = PostalAddress.create(address);
@ -126,19 +126,12 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
}
const postalAddress = postalAddressResult.data;
// Customer Taxes
const taxesResult = CustomerTaxes.create(defaultTaxes);
if (taxesResult.isFailure) {
return Result.fail(taxesResult.error);
}
const taxes = taxesResult.data;
// Reglas de negocio / validaciones
// ...
// ...
// Crear instancia de Customer
const contact = new Customer(internalProps, postalAddress, taxes, id);
const contact = new Customer(internalProps, postalAddress, id);
// Disparar eventos de dominio
// ...
@ -152,17 +145,12 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
}
// Rehidratación desde persistencia
static rehydrate(
props: CustomerInternalProps,
address: PostalAddress,
defaultTaxes: CustomerTaxes,
id: UniqueID
): Customer {
return new Customer(props, address, defaultTaxes, id);
static rehydrate(props: CustomerInternalProps, address: PostalAddress, id: UniqueID): Customer {
return new Customer(props, address, id);
}
public update(partialCustomer: CustomerPatchProps): Result<void, Error> {
const { address: partialAddress, defaultTaxes: partialTaxes, ...rest } = partialCustomer;
const { address: partialAddress, ...rest } = partialCustomer;
const nextProps: CustomerInternalProps = {
...this.props,
@ -170,7 +158,6 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
};
let nextAddress = this._address;
let nextDefaultTaxes = this._defaultTaxes;
if (partialAddress) {
const nextAddressResult = PostalAddress.create({
@ -185,22 +172,8 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
nextAddress = nextAddressResult.data;
}
if (partialTaxes) {
const nextTaxesResult = CustomerTaxes.create({
...this._defaultTaxes.getProps(),
...partialTaxes,
});
if (nextTaxesResult.isFailure) {
return Result.fail(nextTaxesResult.error);
}
nextDefaultTaxes = nextTaxesResult.data;
}
Object.assign(this.props, nextProps);
this._address = nextAddress;
this._defaultTaxes = nextDefaultTaxes;
return Result.ok();
}
@ -279,8 +252,24 @@ export class Customer extends AggregateRoot<CustomerInternalProps> implements IC
return this.props.legalRecord;
}
public get defaultTaxes(): CustomerTaxes {
return this._defaultTaxes;
public get paymentMethodId(): Maybe<UniqueID> {
return this.props.paymentMethodId;
}
public get paymentTermId(): Maybe<UniqueID> {
return this.props.paymentTermId;
}
public get taxRegimeCode(): Maybe<string> {
return this.props.taxRegimeCode;
}
public get usesEquivalenceSurcharge(): boolean {
return this.props.usesEquivalenceSurcharge;
}
public get usesRetention(): boolean {
return this.props.usesRetention;
}
public get languageCode(): LanguageCode {

View File

@ -24,12 +24,7 @@ import {
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
Customer,
type CustomerInternalProps,
CustomerStatus,
CustomerTaxes,
} from "../../../../../domain";
import { Customer, type CustomerInternalProps, CustomerStatus } from "../../../../../domain";
import type { CustomerCreationAttributes, CustomerModel } from "../../models";
export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
@ -179,12 +174,30 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
errors
);
const defaultTaxes = extractOrPushError(
CustomerTaxes.fromKey(source.default_taxes, this.taxCatalog),
"default_taxes",
const paymentMethodId = extractOrPushError(
maybeFromNullableResult(source.payment_method_id, (value) =>
UniqueID.create(String(value))
),
"payment_method_id",
errors
);
const paymentTermId = extractOrPushError(
maybeFromNullableResult(source.payment_term_id, (value) => UniqueID.create(String(value))),
"payment_term_id",
errors
);
// Tax regime code
const taxRegimeCode = extractOrPushError(
maybeFromNullableResult(source.tax_regime_code, (value) => Result.ok(String(value))),
"tax_regime_code",
errors
);
const usesEquivalenceSurcharge = source.uses_equivalence_surcharge;
const usesRetention = source.uses_retention;
// Now, create the PostalAddress VO
const postalAddressProps = {
street: street!,
@ -226,16 +239,18 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
website: website!,
legalRecord: legalRecord!,
paymentMethodId: paymentMethodId!,
paymentTermId: paymentTermId!,
taxRegimeCode: taxRegimeCode!,
usesEquivalenceSurcharge: usesEquivalenceSurcharge,
usesRetention: usesRetention,
languageCode: languageCode!,
currencyCode: currencyCode!,
};
const customer = Customer.rehydrate(
customerProps,
postalAddress!,
defaultTaxes!,
customerId!
);
const customer = Customer.rehydrate(customerProps, postalAddress!, customerId!);
return Result.ok(customer);
} catch (err: unknown) {
return Result.fail(err as Error);
@ -266,7 +281,12 @@ export class SequelizeCustomerDomainMapper extends SequelizeDomainMapper<
website: maybeToNullable(source.website, (website) => website.toPrimitive()),
legal_record: maybeToNullable(source.legalRecord, (legalRecord) => legalRecord.toPrimitive()),
default_taxes: source.defaultTaxes.toKey(),
payment_method_id: maybeToNullable(source.paymentMethodId, (value) => value.toPrimitive()),
payment_term_id: maybeToNullable(source.paymentTermId, (value) => value.toPrimitive()),
tax_regime_code: maybeToNullable(source.taxRegimeCode, (value) => value),
uses_equivalence_surcharge: source.usesEquivalenceSurcharge,
uses_retention: source.usesRetention,
status: source.isActive ? "active" : "inactive",
language_code: source.languageCode.toPrimitive(),

View File

@ -51,8 +51,14 @@ export class CustomerModel extends Model<
declare legal_record: CreationOptional<string | null>;
declare default_taxes: string;
declare payment_method_id: CreationOptional<string | null>;
declare payment_term_id: CreationOptional<string | null>;
declare tax_regime_code: CreationOptional<string | null>;
declare uses_equivalence_surcharge: CreationOptional<boolean>;
declare uses_retention: CreationOptional<boolean>;
declare status: string;
declare language_code: CreationOptional<string>;
declare currency_code: CreationOptional<string>;
@ -189,10 +195,33 @@ export default (database: Sequelize) => {
allowNull: true,
},
default_taxes: {
type: DataTypes.STRING,
defaultValue: null,
payment_method_id: {
type: DataTypes.UUID,
allowNull: true,
defaultValue: null,
},
payment_term_id: {
type: DataTypes.UUID,
allowNull: true,
defaultValue: null,
},
tax_regime_code: {
type: DataTypes.STRING(2),
allowNull: true,
},
uses_equivalence_surcharge: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
uses_retention: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
status: {

View File

@ -29,6 +29,12 @@ export const CreateCustomerRequestSchema = z.object({
legal_record: z.string().optional(),
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(),
language_code: z.string().toLowerCase().default("es"),
currency_code: z.string().toUpperCase().default("EUR"),
});

View File

@ -11,8 +11,6 @@ import {
} from "@erp/core";
import { z } from "zod/v4";
import { TaxCombinationCodeSchema } from "../shared";
export const UpdateCustomerByIdParamsRequestSchema = z.object({
customer_id: z.uuid(),
});
@ -49,7 +47,11 @@ export const UpdateCustomerByIdRequestSchema = z.object({
trade_name: z.string().nullable().optional(),
tin: TinSchema.nullable().optional(),
default_taxes: TaxCombinationCodeSchema.optional(),
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(),
address: UpdateCustomerAddressPatchRequestSchema.optional(),
contact: UpdateCustomerContactPatchRequestSchema.optional(),

View File

@ -12,7 +12,11 @@ import {
} from "@erp/core";
import { z } from "zod/v4";
import { TaxCombinationCodeSchema } from "../shared";
import {
CustomerPaymentMethodRefSchema,
CustomerPaymentTermRefSchema,
CustomerTaxRegimeRefSchema,
} from "../shared";
import { CustomerStatusSchema } from "../shared/customer-status.dto";
export const GetCustomerByIdResponseSchema = z.object({
@ -49,7 +53,13 @@ export const GetCustomerByIdResponseSchema = z.object({
legal_record: z.string().nullable(),
default_taxes: TaxCombinationCodeSchema,
payment_method: CustomerPaymentMethodRefSchema.nullable(),
payment_term: CustomerPaymentTermRefSchema.nullable(),
tax_regime: CustomerTaxRegimeRefSchema.nullable(),
fiscal_defaults: z.object({
uses_equivalence_surcharge: z.boolean(),
uses_retention: z.boolean(),
}),
language_code: LanguageCodeSchema,
currency_code: CurrencyCodeSchema,

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const CustomerPaymentMethodRefSchema = z.object({
id: z.uuid(),
name: z.string(),
description: z.string(),
});
export type CustomerPaymentMethodRefDTO = z.infer<typeof CustomerPaymentMethodRefSchema>;

View File

@ -0,0 +1,8 @@
import { z } from "zod/v4";
export const CustomerPaymentTermRefSchema = z.object({
id: z.uuid(),
description: z.string(),
});
export type CustomerPaymentTermRefDTO = z.infer<typeof CustomerPaymentTermRefSchema>;

View File

@ -0,0 +1,8 @@
import { z } from "zod/v4";
export const CustomerTaxRegimeRefSchema = z.object({
code: z.string(),
description: z.string(),
});
export type CustomerTaxRegimeRefDTO = z.infer<typeof CustomerTaxRegimeRefSchema>;

View File

@ -1,3 +1,5 @@
export * from "./customer-payment-method-ref.dto";
export * from "./customer-payment-term-ref.dto";
export * from "./customer-status.dto";
export * from "./customer-summary.dto";
export * from "./tax-combination-code.dto";
export * from "./customer-tax-regime-ref.dto";

View File

@ -1,23 +0,0 @@
import { z } from "zod/v4";
const TAX_CODE_PATTERN = /^[a-z0-9_]+$/i;
const EMPTY_TAX_SLOT = "#";
export const TaxCombinationCodeSchema = z.string().refine(
(value) => {
const parts = value.split(";");
if (parts.length !== 3) {
return false;
}
return parts.every((part) => {
return part === EMPTY_TAX_SLOT || TAX_CODE_PATTERN.test(part);
});
},
{
message: "taxes must use format '<iva_code|#>;<rec_code|#>;<retention_code|#>'",
}
);
export type TaxCombinationCodeDTO = z.infer<typeof TaxCombinationCodeSchema>;

View File

@ -2,16 +2,14 @@ import { DataTableColumnHeader, InitialsAvatar } from "@repo/rdx-ui/components";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Checkbox,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { Building2Icon, EyeIcon, MoreHorizontalIcon, UserIcon } from "lucide-react";
import { Building2Icon, PencilIcon, Trash2Icon, UserIcon } from "lucide-react";
import * as React from "react";
import { useTranslation } from "../../../../i18n";
@ -23,16 +21,43 @@ type GridActionHandlers = {
onViewClick?: (customer: CustomerListRow) => void;
onSummaryClick?: (customer: CustomerListRow) => void;
onDeleteClick?: (customer: CustomerListRow) => void;
canEdit?: (proforma: CustomerListRow) => boolean;
canDelete?: (proforma: CustomerListRow) => boolean;
};
export function useCustomersGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<CustomerListRow, unknown>[] {
const { t } = useTranslation();
const { onEditClick, onViewClick, onDeleteClick, onSummaryClick } = actionHandlers;
return React.useMemo<ColumnDef<CustomerListRow, unknown>[]>(
() => [
{
id: "select",
header: ({ table }) => {
const isAllSelected = table.getIsAllPageRowsSelected();
const isSomeSelected = table.getIsSomePageRowsSelected();
return (
<Checkbox
aria-checked={isSomeSelected ? "mixed" : isAllSelected}
aria-label="Seleccionar todo"
checked={isAllSelected}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
);
},
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableHiding: false,
enableSorting: false,
},
{
id: "customer",
header: ({ column }) => (
@ -52,7 +77,9 @@ export function useCustomersGridColumns(
return (
<button
className="flex items-start gap-3 text-left transition-colors hover:opacity-80 cursor-pointer"
onClick={onSummaryClick ? () => onSummaryClick(customer) : undefined}
onClick={() =>
actionHandlers.onSummaryClick ? actionHandlers.onSummaryClick(customer) : null
}
type="button"
>
<div className="flex items-start gap-3">
@ -124,15 +151,11 @@ export function useCustomersGridColumns(
},
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("list.columns.actions")}
/>
),
size: 64,
minSize: 64,
meta: {
isActionsColumn: true,
headerClassName: "text-right",
},
header: "Acciones",
enableSorting: false,
enableHiding: false,
cell: ({ row }) => {
@ -140,7 +163,68 @@ export function useCustomersGridColumns(
const { primaryEmail } = customer.contact;
return (
<div className="flex justify-end">
<div className="flex items-center gap-1 justify-end">
{actionHandlers.onEditClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onEditClick?.(customer);
}}
size="icon"
variant="ghost"
>
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</Button>
}
/>
<TooltipContent>Editar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Eliminar */}
{!actionHandlers.onDeleteClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-8 text-destructive/75 hover:text-destructive cursor-pointer"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onDeleteClick?.(customer);
}}
size="icon"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
}
/>
<TooltipContent>Eliminar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
},
],
[t, actionHandlers]
);
}
/*
<div className="flex items-center gap-1">
<Button
aria-label={t("list.actions.view")}
@ -198,11 +282,5 @@ export function useCustomersGridColumns(
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
},
},
],
[t, onDeleteClick, onEditClick, onViewClick, onSummaryClick]
);
}
*/

View File

@ -1,4 +1,5 @@
import { useUrlParamId } from "@erp/core/hooks";
import { useSearchParams } from "react-router-dom";
import { useCustomerUpdateController } from "./use-customer-update.controller";
@ -10,10 +11,14 @@ import { useCustomerUpdateController } from "./use-customer-update.controller";
export const useCustomerUpdatePageController = () => {
const customerId = useUrlParamId();
const [searchParams] = useSearchParams();
const updateCtrl = useCustomerUpdateController(customerId);
const returnTo = searchParams.get("returnTo") ?? "/customers";
return {
updateCtrl,
returnTo,
};
};

View File

@ -0,0 +1,123 @@
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 CustomerUpdateHeaderLabels {
title: string;
back: string;
modified: string;
cancel: string;
save: string;
saving: string;
moreActions: string;
keyboardShortcuts: string;
duplicate: string;
delete: string;
}
export interface CustomerUpdateHeaderProps {
formId?: string;
labels: CustomerUpdateHeaderLabels;
onCancel?: () => void;
onDuplicate?: () => void;
onDelete?: () => void;
disabled?: boolean;
readOnly?: boolean;
isSaving?: boolean;
hasChanges?: boolean;
className?: string;
children?: ReactNode;
}
export const CustomerUpdateHeader = ({
formId,
labels,
onCancel,
onDuplicate,
onDelete,
disabled = false,
readOnly = false,
isSaving = false,
hasChanges = false,
className,
children,
}: CustomerUpdateHeaderProps) => {
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-update-header";

View File

@ -1,19 +1,26 @@
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
import { UnsavedChangesProvider } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
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";
import { useTranslation } from "../../../i18n";
import { useCustomerUpdatePageController } from "../../controllers";
import { CustomerUpdateHeader } from "../blocks";
import { CustomerEditorSkeleton } from "../components";
import { CustomerUpdateEditorForm } from "../editor";
export const CustomerUpdatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { updateCtrl } = useCustomerUpdatePageController();
const { updateCtrl, returnTo } = useCustomerUpdatePageController();
const { navigateBack } = useReturnToNavigation({
fallbackPath: returnTo,
});
const handleCancel = () => navigateBack();
if (updateCtrl.isLoading) {
return <CustomerEditorSkeleton />;
@ -49,41 +56,30 @@ export const CustomerUpdatePage = () => {
);
return (
<FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<AppHeader className="space-y-4 max-w-5xl mx-auto">
<PageHeader
description={t("update.description")}
onBackClick={() => navigate("/customers/list")}
rightSlot={
/*
<FormCommitButtonGroup
cancel={{
to: "/customers/list",
}}
disabled={updateCtrl.isUpdating}
isLoading={updateCtrl.isUpdating}
onReset={updateCtrl.form.formState.isDirty ? updateCtrl.resetForm : undefined}
submit={{
formId: updateCtrl.formId,
}}
/>
*/ <></>
}
title={t("update.title")}
<div className="fixed inset-0 flex flex-col overflow-hidden">
<FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<CustomerUpdateHeader
formId={updateCtrl.formId}
hasChanges={updateCtrl.form.formState.isDirty}
isSaving={updateCtrl.form.formState.isSubmitting}
labels={{
title: "Editar cliente",
back: "Volver",
modified: "Modificada",
cancel: "Cancelar",
save: "Guardar",
saving: "Guardando...",
moreActions: "Más acciones",
keyboardShortcuts: "Ver atajos de teclado",
duplicate: "Duplicar cliente",
delete: "Eliminar",
}}
onCancel={handleCancel}
//onDelete={openDeleteDialog}
//onDuplicate={handleDuplicate}
//onExportPdf={handleExportPdf}
/>
</AppHeader>
<AppContent className="space-y-4 max-w-5xl mx-auto">
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t("update.errors.message")
}
title={t("update.errors.title")}
/>
)}
{updateCtrl.isLoading && <Spinner />}
@ -95,8 +91,8 @@ export const CustomerUpdatePage = () => {
onSubmit={updateCtrl.onSubmit}
/>
)}
</AppContent>
</UnsavedChangesProvider>
</FormProvider>
</UnsavedChangesProvider>
</FormProvider>
</div>
);
};

View File

@ -34,11 +34,11 @@ export class SupplierInvoiceRepository
try {
const dtoResult = this.domainMapper.mapToPersistence(invoice);
if (dtoResult.isFailure()) {
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
const dto = dtoResult.value;
const dto = dtoResult.data;
const { id, taxes, ...createPayload } = dto;
await SupplierInvoiceModel.create(
@ -74,11 +74,11 @@ export class SupplierInvoiceRepository
try {
const dtoResult = this.domainMapper.mapToPersistence(invoice);
if (dtoResult.isFailure()) {
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
const dto = dtoResult.value;
const dto = dtoResult.data;
const { id, company_id, version, taxes, ...updatePayload } = dto;
const [updatedRows] = await SupplierInvoiceModel.update(
@ -309,14 +309,23 @@ export class SupplierInvoiceRepository
try {
const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, {
const criteriaQuery = criteriaConverter.convert(criteria, {
searchableFields: ["invoice_number", "description", "internal_notes"],
mappings: {
invoice_number: "SupplierInvoiceModel.invoice_number",
reference: "SupplierInvoiceModel.description",
description: "SupplierInvoiceModel.internal_notes",
invoice_number: {
type: "root",
column: "invoice_number",
},
reference: {
type: "root",
column: "reference",
},
description: {
type: "root",
column: "description",
},
},
allowedFields: ["invoice_date", "due_date", "id", "created_at"],
sortableFields: ["invoice_date", "due_date", "id", "created_at"],
enableFullText: true,
database: this.database,
strictMode: true,
@ -334,16 +343,16 @@ export class SupplierInvoiceRepository
? [options.include]
: [];
query.where = {
...query.where,
criteriaQuery.where = {
...criteriaQuery.where,
...(options.where ?? {}),
company_id: companyId.toString(),
deleted_at: null,
};
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
criteriaQuery.order = [...(criteriaQuery.order as OrderItem[]), ...normalizedOrder];
query.include = [
criteriaQuery.include = [
...normalizedInclude,
{
model: SupplierInvoiceTaxModel,
@ -355,11 +364,11 @@ export class SupplierInvoiceRepository
const [rows, count] = await Promise.all([
SupplierInvoiceModel.findAll({
...query,
...criteriaQuery,
transaction,
}),
SupplierInvoiceModel.count({
where: query.where,
where: criteriaQuery.where,
distinct: true,
transaction,
}),

View File

@ -242,7 +242,7 @@ export class SupplierRepository
"email_primary",
"mobile_primary",
],
allowedFields: [
sortableFields: [
"name",
"trade_name",
"reference",

View File

@ -692,6 +692,9 @@ importers:
'@erp/auth':
specifier: workspace:*
version: link:../auth
'@erp/catalogs':
specifier: workspace:*
version: link:../catalogs
'@erp/core':
specifier: workspace:*
version: link:../core