.
This commit is contained in:
parent
6c11dd7027
commit
f6f57359d5
@ -1,4 +1,4 @@
|
||||
export * from "./mappers";
|
||||
export * from "./models/";
|
||||
export * from "./services";
|
||||
export * from "./services/";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./mappers";
|
||||
export * from "./models";
|
||||
export * from "./services";
|
||||
@ -0,0 +1,3 @@
|
||||
|
||||
export * from "./payment-term-public-model.mapper";
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./payment-term-public.model";
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./payment-term-public-finder";
|
||||
@ -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>()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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, "''")}'`;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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:*",
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Datos del régimen de pago que completan al cliente
|
||||
*/
|
||||
|
||||
export interface CustomerTaxRegimeFullReadModel {
|
||||
code: string;
|
||||
description: string;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./domain";
|
||||
export * from "./queries";
|
||||
@ -1 +0,0 @@
|
||||
export * from "./list-customers.presenter";
|
||||
@ -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}`,
|
||||
//},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./customer-full-read-model.assembler";
|
||||
@ -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(),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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"),
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>;
|
||||
@ -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>;
|
||||
@ -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>;
|
||||
@ -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";
|
||||
|
||||
@ -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>;
|
||||
@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
1
modules/customers/src/web/update/ui/blocks/index.ts
Normal file
1
modules/customers/src/web/update/ui/blocks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer-update-header";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -242,7 +242,7 @@ export class SupplierRepository
|
||||
"email_primary",
|
||||
"mobile_primary",
|
||||
],
|
||||
allowedFields: [
|
||||
sortableFields: [
|
||||
"name",
|
||||
"trade_name",
|
||||
"reference",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user