Proformas

This commit is contained in:
David Arranz 2025-11-16 20:30:11 +01:00
parent 9b874eebf8
commit f19ab6022b
74 changed files with 810 additions and 940 deletions

View File

@ -1,12 +1,12 @@
import { Presenter } from "@erp/core/api";
import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
import { toEmptyString } from "@repo/rdx-ddd";
import type { ArrayElement } from "@repo/rdx-utils";
import type { GetIssueInvoiceByIdResponseDTO } from "../../../../common/dto";
import type { CustomerInvoiceItem, CustomerInvoiceItems } from "../../../domain";
type GetCustomerInvoiceItemByInvoiceIdResponseDTO = ArrayElement<
GetIssueInvoiceByIdResponseDTO["items"]
GetIssuedInvoiceByIdResponseDTO["items"]
>;
export class CustomerInvoiceItemsFullPresenter extends Presenter {
@ -49,7 +49,7 @@ export class CustomerInvoiceItemsFullPresenter extends Presenter {
};
}
toOutput(invoiceItems: CustomerInvoiceItems): GetIssueInvoiceByIdResponseDTO["items"] {
toOutput(invoiceItems: CustomerInvoiceItems): GetIssuedInvoiceByIdResponseDTO["items"] {
return invoiceItems.map(this._mapItem);
}
}

View File

@ -1,7 +1,7 @@
import { Presenter } from "@erp/core/api";
import { toEmptyString } from "@repo/rdx-ddd";
import type { GetIssueInvoiceByIdResponseDTO } from "../../../../common/dto";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto";
import type { CustomerInvoice } from "../../../domain";
import type { CustomerInvoiceItemsFullPresenter } from "./customer-invoice-items.full.presenter";
@ -9,9 +9,9 @@ import type { RecipientInvoiceFullPresenter } from "./recipient-invoice.full.rep
export class CustomerInvoiceFullPresenter extends Presenter<
CustomerInvoice,
GetIssueInvoiceByIdResponseDTO
GetIssuedInvoiceByIdResponseDTO
> {
toOutput(invoice: CustomerInvoice): GetIssueInvoiceByIdResponseDTO {
toOutput(invoice: CustomerInvoice): GetIssuedInvoiceByIdResponseDTO {
const itemsPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice-items",
projection: "FULL",

View File

@ -1,10 +1,10 @@
import { Presenter } from "@erp/core/api";
import { DomainValidationError, toEmptyString } from "@repo/rdx-ddd";
import type { GetIssueInvoiceByIdResponseDTO } from "../../../../common/dto";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto";
import type { CustomerInvoice, InvoiceRecipient } from "../../../domain";
type GetRecipientInvoiceByInvoiceIdResponseDTO = GetIssueInvoiceByIdResponseDTO["recipient"];
type GetRecipientInvoiceByInvoiceIdResponseDTO = GetIssuedInvoiceByIdResponseDTO["recipient"];
export class RecipientInvoiceFullPresenter extends Presenter {
toOutput(invoice: CustomerInvoice): GetRecipientInvoiceByInvoiceIdResponseDTO {

View File

@ -1,9 +1,9 @@
import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/core";
import { type IPresenterOutputParams, Presenter } from "@erp/core/api";
import type { GetIssueInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
type CustomerInvoiceItemsDTO = GetIssueInvoiceByIdResponseDTO["items"];
type CustomerInvoiceItemsDTO = GetIssuedInvoiceByIdResponseDTO["items"];
type CustomerInvoiceItemDTO = ArrayElement<CustomerInvoiceItemsDTO>;
export class CustomerInvoiceItemsReportPersenter extends Presenter<

View File

@ -1,9 +1,9 @@
import { type JsonTaxCatalogProvider, MoneyDTOHelper, SpainTaxCatalogProvider } from "@erp/core";
import { type IPresenterOutputParams, Presenter } from "@erp/core/api";
import type { GetIssueInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
type CustomerInvoiceTaxesDTO = GetIssueInvoiceByIdResponseDTO["taxes"];
type CustomerInvoiceTaxesDTO = GetIssuedInvoiceByIdResponseDTO["taxes"];
type CustomerInvoiceTaxDTO = ArrayElement<CustomerInvoiceTaxesDTO>;
export class CustomerInvoiceTaxesReportPresenter extends Presenter<

View File

@ -1,14 +1,14 @@
import { DateHelper, MoneyDTOHelper, PercentageDTOHelper } from "@erp/core";
import { Presenter } from "@erp/core/api";
import type { GetIssueInvoiceByIdResponseDTO } from "../../../../common/dto";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto";
export class CustomerInvoiceReportPresenter extends Presenter<
GetIssueInvoiceByIdResponseDTO,
GetIssuedInvoiceByIdResponseDTO,
unknown
> {
private _formatPaymentMethodDTO(
paymentMethod?: GetIssueInvoiceByIdResponseDTO["payment_method"]
paymentMethod?: GetIssuedInvoiceByIdResponseDTO["payment_method"]
) {
if (!paymentMethod) {
return "";
@ -17,7 +17,7 @@ export class CustomerInvoiceReportPresenter extends Presenter<
return paymentMethod.payment_description ?? "";
}
toOutput(invoiceDTO: GetIssueInvoiceByIdResponseDTO) {
toOutput(invoiceDTO: GetIssuedInvoiceByIdResponseDTO) {
const itemsPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice-items",
projection: "REPORT",

View File

@ -3,14 +3,14 @@ import type { Criteria } from "@repo/rdx-criteria/server";
import { toEmptyString } from "@repo/rdx-ddd";
import type { ArrayElement, Collection } from "@repo/rdx-utils";
import type { ListIssueInvoicesResponseDTO } from "../../../../common/dto";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
import type { CustomerInvoiceListDTO } from "../../../infrastructure";
export class ListCustomerInvoicesPresenter extends Presenter {
protected _mapInvoice(invoice: CustomerInvoiceListDTO) {
const recipientDTO = invoice.recipient.toObjectString();
const invoiceDTO: ArrayElement<ListIssueInvoicesResponseDTO["items"]> = {
const invoiceDTO: ArrayElement<ListIssuedInvoicesResponseDTO["items"]> = {
id: invoice.id.toString(),
company_id: invoice.companyId.toString(),
is_proforma: invoice.isProforma,
@ -50,7 +50,7 @@ export class ListCustomerInvoicesPresenter extends Presenter {
toOutput(params: {
customerInvoices: Collection<CustomerInvoiceListDTO>;
criteria: Criteria;
}): ListIssueInvoicesResponseDTO {
}): ListIssuedInvoicesResponseDTO {
const { customerInvoices, criteria } = params;
const invoices = customerInvoices.map((invoice) => this._mapInvoice(invoice));

View File

@ -48,7 +48,7 @@ export class CustomerInvoiceApplicationService {
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoiceNumber, Error> - El agregado construido o un error si falla la creación.
*/
async getNextIssueInvoiceNumber(
async getNextIssuedInvoiceNumber(
companyId: UniqueID,
series: Maybe<CustomerInvoiceSerie>,
transaction: Transaction
@ -80,7 +80,7 @@ export class CustomerInvoiceApplicationService {
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice, Error> - La factura guardada o un error si falla la operación.
*/
async createIssueInvoiceInCompany(
async createIssuedInvoiceInCompany(
companyId: UniqueID,
invoice: CustomerInvoice,
transaction: Transaction
@ -90,7 +90,7 @@ export class CustomerInvoiceApplicationService {
return Result.fail(result.error);
}
return this.getIssueInvoiceByIdInCompany(companyId, invoice.id, transaction);
return this.getIssuedInvoiceByIdInCompany(companyId, invoice.id, transaction);
}
/**
@ -157,7 +157,7 @@ export class CustomerInvoiceApplicationService {
/**
*
* Comprueba si existe o no en persistencia una proforma con el ID proporcionado
* Comprueba si existe o no en persistencia una factura con el ID proporcionado
*
* @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param invoiceId - Identificador UUID de la factura.
@ -165,7 +165,7 @@ export class CustomerInvoiceApplicationService {
* @returns Result<Boolean, Error> - Existe la factura o no.
*/
async existsIssueInvoiceByIdInCompany(
async existsIssuedInvoiceByIdInCompany(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
@ -178,7 +178,7 @@ export class CustomerInvoiceApplicationService {
/**
* Obtiene una colección de proformas que cumplen con los filtros definidos en un objeto Criteria.
*
* @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param companyId - Identificador de la empresa a la que pertenece la proforma.
* @param criteria - Objeto con condiciones de filtro, paginación y orden.
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<ProformasListDTO>, Error> - Colección de proformas o error.
@ -203,7 +203,7 @@ export class CustomerInvoiceApplicationService {
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<CustomerInvoiceListDTO>, Error> - Colección de facturas o error.
*/
async findIssueInvoiceByCriteriaInCompany(
async findIssuedInvoiceByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
@ -222,7 +222,7 @@ export class CustomerInvoiceApplicationService {
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice, Error> - Factura encontrada o error.
*/
async getIssueInvoiceByIdInCompany(
async getIssuedInvoiceByIdInCompany(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction

View File

@ -1,2 +1,2 @@
export * from "./issue-invoices";
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -1,3 +0,0 @@
export * from "./get-issue-invoice.use-case";
export * from "./list-issue-invoices.use-case";
export * from "./report-issue-invoice.use-case";

View File

@ -5,19 +5,19 @@ import { Result } from "@repo/rdx-utils";
import type { CustomerInvoiceFullPresenter } from "../../presenters/domain";
import type { CustomerInvoiceApplicationService } from "../../services";
type GetIssueInvoiceUseCaseInput = {
type GetIssuedInvoiceUseCaseInput = {
companyId: UniqueID;
invoice_id: string;
};
export class GetIssueInvoiceUseCase {
export class GetIssuedInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}
public execute(params: GetIssueInvoiceUseCaseInput) {
public execute(params: GetIssuedInvoiceUseCaseInput) {
const { invoice_id, companyId } = params;
const idOrError = UniqueID.create(invoice_id);
@ -33,7 +33,7 @@ export class GetIssueInvoiceUseCase {
return this.transactionManager.complete(async (transaction) => {
try {
const invoiceOrError = await this.service.getIssueInvoiceByIdInCompany(
const invoiceOrError = await this.service.getIssuedInvoiceByIdInCompany(
companyId,
invoiceId,
transaction

View File

@ -0,0 +1,3 @@
export * from "./get-issued-invoice.use-case";
export * from "./list-issued-invoices.use-case";
export * from "./report-issued-invoice.use-case";

View File

@ -4,16 +4,16 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { ListIssueInvoicesResponseDTO } from "../../../../common/dto";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
import type { ListCustomerInvoicesPresenter } from "../../presenters";
import type { CustomerInvoiceApplicationService } from "../../services";
type ListIssueInvoicesUseCaseInput = {
type ListIssuedInvoicesUseCaseInput = {
companyId: UniqueID;
criteria: Criteria;
};
export class ListIssueInvoicesUseCase {
export class ListIssuedInvoicesUseCase {
constructor(
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
@ -21,8 +21,8 @@ export class ListIssueInvoicesUseCase {
) {}
public execute(
params: ListIssueInvoicesUseCaseInput
): Promise<Result<ListIssueInvoicesResponseDTO, Error>> {
params: ListIssuedInvoicesUseCaseInput
): Promise<Result<ListIssuedInvoicesResponseDTO, Error>> {
const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
@ -31,7 +31,7 @@ export class ListIssueInvoicesUseCase {
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const result = await this.service.findIssueInvoiceByCriteriaInCompany(
const result = await this.service.findIssuedInvoiceByCriteriaInCompany(
companyId,
criteria,
transaction

View File

@ -5,19 +5,19 @@ import { Result } from "@repo/rdx-utils";
import type { CustomerInvoiceApplicationService } from "../../services";
import type { CustomerInvoiceReportPDFPresenter } from "../proformas";
type ReportIssueInvoiceUseCaseInput = {
type ReportIssuedInvoiceUseCaseInput = {
companyId: UniqueID;
invoice_id: string;
};
export class ReportIssueInvoiceUseCase {
export class ReportIssuedInvoiceUseCase {
constructor(
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}
public async execute(params: ReportIssueInvoiceUseCaseInput) {
public async execute(params: ReportIssuedInvoiceUseCaseInput) {
const { invoice_id, companyId } = params;
const idOrError = UniqueID.create(invoice_id);
@ -35,7 +35,7 @@ export class ReportIssueInvoiceUseCase {
return this.transactionManager.complete(async (transaction) => {
try {
const invoiceOrError = await this.service.getIssueInvoiceByIdInCompany(
const invoiceOrError = await this.service.getIssuedInvoiceByIdInCompany(
companyId,
invoiceId,
transaction

View File

@ -9,7 +9,7 @@ import {
import type { CustomerInvoiceFullPresenter } from "../../presenters";
import type { CustomerInvoiceApplicationService } from "../../services";
type IssueCustomerInvoiceUseCaseInput = {
type IssueProformaUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
};
@ -23,7 +23,7 @@ type IssueCustomerInvoiceUseCaseInput = {
* - Marca la proforma como "issued"
* - Persiste ambas dentro de la misma transacción
*/
export class IssueProformaInvoiceUseCase {
export class IssueProformaUseCase {
private readonly issueDomainService: IssueCustomerInvoiceDomainService;
private readonly proformaDomainService: ProformaCustomerInvoiceDomainService;
@ -36,7 +36,7 @@ export class IssueProformaInvoiceUseCase {
this.proformaDomainService = new ProformaCustomerInvoiceDomainService();
}
public execute(params: IssueCustomerInvoiceUseCaseInput) {
public execute(params: IssueProformaUseCaseInput) {
const { proforma_id, companyId } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) return Result.fail(idOrError.error);
@ -59,7 +59,7 @@ export class IssueProformaInvoiceUseCase {
const proforma = proformaResult.data;
/** 2. Generar nueva factura */
const nextNumberResult = await this.service.getNextIssueInvoiceNumber(
const nextNumberResult = await this.service.getNextIssuedInvoiceNumber(
companyId,
proforma.series,
transaction
@ -74,7 +74,7 @@ export class IssueProformaInvoiceUseCase {
if (issuedInvoiceOrError.isFailure) return Result.fail(issuedInvoiceOrError.error);
/** 5. Guardar la nueva factura */
const saveInvoiceResult = await this.service.createIssueInvoiceInCompany(
const saveInvoiceResult = await this.service.createIssuedInvoiceInCompany(
companyId,
issuedInvoiceOrError.data,
transaction

View File

@ -4,7 +4,7 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { ListIssueInvoicesResponseDTO } from "../../../../common/dto";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
import type { ListCustomerInvoicesPresenter } from "../../presenters";
import type { CustomerInvoiceApplicationService } from "../../services";
@ -22,7 +22,7 @@ export class ListProformasUseCase {
public execute(
params: ListProformasUseCaseInput
): Promise<Result<ListIssueInvoicesResponseDTO, Error>> {
): Promise<Result<ListIssuedInvoicesResponseDTO, Error>> {
const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",

View File

@ -1,9 +1,11 @@
import { Tax } from "@erp/core/api";
import { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import type { Tax } from "@erp/core/api";
import type { CurrencyCode, LanguageCode } from "@repo/rdx-ddd";
import { Collection } from "@repo/rdx-utils";
import { ItemAmount } from "../../value-objects";
import { ItemTaxes } from "../item-taxes";
import { CustomerInvoiceItem } from "./customer-invoice-item";
import type { CustomerInvoiceItem } from "./customer-invoice-item";
export interface CustomerInvoiceItemsProps {
items?: CustomerInvoiceItem[];
@ -39,8 +41,9 @@ export class CustomerInvoiceItems extends Collection<CustomerInvoiceItem> {
// Antes de añadir un nuevo item, debo comprobar que el item a añadir
// tiene el mismo "currencyCode" y "languageCode" que la colección de items.
if (
!this._languageCode.equals(item.languageCode) ||
!this._currencyCode.equals(item.currencyCode)
!(
this._languageCode.equals(item.languageCode) && this._currencyCode.equals(item.currencyCode)
)
) {
return false;
}

View File

@ -1,15 +1,15 @@
import {
City,
Country,
Name,
PostalCode,
Province,
Street,
TINNumber,
type City,
type Country,
type Name,
type PostalCode,
type Province,
type Street,
type TINNumber,
ValueObject,
toEmptyString,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { type Maybe, Result } from "@repo/rdx-utils";
export interface InvoiceRecipientProps {
name: Name;

View File

@ -3,7 +3,7 @@ import type { UniqueID } from "@repo/rdx-ddd";
import type { Transaction } from "sequelize";
import { buildCustomerInvoiceDependencies, models, proformasRouter } from "./infrastructure";
import { issueInvoicesRouter } from "./infrastructure/express/issue-invoices.routes";
import { issuedInvoicesRouter } from "./infrastructure/express/issued-invoices.routes";
export const customerInvoicesAPIModule: IModuleServer = {
name: "customer-invoices",
@ -14,7 +14,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
proformasRouter(params);
issueInvoicesRouter(params);
issuedInvoicesRouter(params);
logger.info("🚀 CustomerInvoices module initialized", { label: this.name });
},

View File

@ -20,14 +20,14 @@ import {
CustomerInvoiceReportPresenter,
CustomerInvoiceTaxesReportPresenter,
DeleteProformaUseCase,
GetIssueInvoiceUseCase,
GetIssuedInvoiceUseCase,
GetProformaUseCase,
IssueProformaInvoiceUseCase,
IssueProformaUseCase,
ListCustomerInvoicesPresenter,
ListIssueInvoicesUseCase,
ListIssuedInvoicesUseCase,
ListProformasUseCase,
RecipientInvoiceFullPresenter,
ReportIssueInvoiceUseCase,
ReportIssuedInvoiceUseCase,
ReportProformaUseCase,
UpdateProformaUseCase,
} from "../application";
@ -52,12 +52,12 @@ export type CustomerInvoiceDeps = {
update_proforma: () => UpdateProformaUseCase;
delete_proforma: () => DeleteProformaUseCase;
report_proforma: () => ReportProformaUseCase;
issue_proforma: () => IssueProformaInvoiceUseCase;
issue_proforma: () => IssueProformaUseCase;
changeStatus_proforma: () => ChangeStatusProformaUseCase;
list_issue_invoices: () => ListIssueInvoicesUseCase;
get_issue_invoice: () => GetIssueInvoiceUseCase;
report_issue_invoice: () => ReportIssueInvoiceUseCase;
list_issued_invoices: () => ListIssuedInvoicesUseCase;
get_issued_invoice: () => GetIssuedInvoiceUseCase;
report_issued_invoice: () => ReportIssuedInvoiceUseCase;
};
};
@ -144,16 +144,16 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
report_proforma: () =>
new ReportProformaUseCase(appService, transactionManager, presenterRegistry),
issue_proforma: () =>
new IssueProformaInvoiceUseCase(appService, transactionManager, presenterRegistry),
new IssueProformaUseCase(appService, transactionManager, presenterRegistry),
changeStatus_proforma: () => new ChangeStatusProformaUseCase(appService, transactionManager),
// Issue Invoices
list_issue_invoices: () =>
new ListIssueInvoicesUseCase(appService, transactionManager, presenterRegistry),
get_issue_invoice: () =>
new GetIssueInvoiceUseCase(appService, transactionManager, presenterRegistry),
report_issue_invoice: () =>
new ReportIssueInvoiceUseCase(appService, transactionManager, presenterRegistry),
list_issued_invoices: () =>
new ListIssuedInvoicesUseCase(appService, transactionManager, presenterRegistry),
get_issued_invoice: () =>
new GetIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
report_issued_invoice: () =>
new ReportIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
};
return {

View File

@ -1,2 +1,2 @@
export * from "./issue-invoices";
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -1,3 +0,0 @@
export * from "./get-issue-invoice.controller";
export * from "./list-issue-invoices.controller";
export * from "./report-issue-invoice.controller";

View File

@ -1,10 +1,10 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import type { GetIssueInvoiceUseCase } from "../../../../application";
import type { GetIssuedInvoiceUseCase } from "../../../../application";
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
export class GetIssueInvoiceController extends ExpressController {
public constructor(private readonly useCase: GetIssueInvoiceUseCase) {
public constructor(private readonly useCase: GetIssuedInvoiceUseCase) {
super();
this.errorMapper = customerInvoicesApiErrorMapper;

View File

@ -0,0 +1,3 @@
export * from "./get-issued-invoice.controller";
export * from "./list-issued-invoices.controller";
export * from "./report-issued-invoice.controller";

View File

@ -1,11 +1,11 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { Criteria } from "@repo/rdx-criteria/server";
import type { ListIssueInvoicesUseCase } from "../../../../application";
import type { ListIssuedInvoicesUseCase } from "../../../../application";
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
export class ListIssueInvoicesController extends ExpressController {
public constructor(private readonly useCase: ListIssueInvoicesUseCase) {
export class ListIssuedInvoicesController extends ExpressController {
public constructor(private readonly useCase: ListIssuedInvoicesUseCase) {
super();
this.errorMapper = customerInvoicesApiErrorMapper;

View File

@ -1,10 +1,10 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import type { ReportIssueInvoiceUseCase } from "../../../../application";
import type { ReportIssuedInvoiceUseCase } from "../../../../application";
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
export class ReportIssueInvoiceController extends ExpressController {
public constructor(private readonly useCase: ReportIssueInvoiceUseCase) {
export class ReportIssuedInvoiceController extends ExpressController {
public constructor(private readonly useCase: ReportIssuedInvoiceUseCase) {
super();
this.errorMapper = customerInvoicesApiErrorMapper;

View File

@ -1,10 +1,10 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import type { IssueProformaInvoiceUseCase } from "../../../../application";
import type { IssueProformaUseCase } from "../../../../application";
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
export class IssueProformaController extends ExpressController {
public constructor(private readonly useCase: IssueProformaInvoiceUseCase) {
public constructor(private readonly useCase: IssueProformaUseCase) {
super();
this.errorMapper = customerInvoicesApiErrorMapper;

View File

@ -11,9 +11,11 @@ import {
import {
type CustomerInvoiceIdAlreadyExistsError,
type EntityIsNotProformaError,
type InvalidProformaTransitionError,
type ProformaCannotBeConvertedToInvoiceError,
isCustomerInvoiceIdAlreadyExistsError,
isEntityIsNotProformaError,
isInvalidProformaTransitionError,
isProformaCannotBeConvertedToInvoiceError,
isProformaCannotBeDeletedError,
} from "../../domain";
@ -38,6 +40,15 @@ const entityIsNotProformaError: ErrorToApiRule = {
),
};
const proformaTransitionRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isInvalidProformaTransitionError(e),
build: (e) =>
new ValidationApiError(
(e as InvalidProformaTransitionError).message || "Invalid transition for proforma."
),
};
const proformaConversionRule: ErrorToApiRule = {
priority: 120,
matches: (e) => isProformaCannotBeConvertedToInvoiceError(e),
@ -62,4 +73,5 @@ export const customerInvoicesApiErrorMapper: ApiErrorMapper = ApiErrorMapper.def
.register(invoiceDuplicateRule)
.register(entityIsNotProformaError)
.register(proformaConversionRule)
.register(proformaCannotBeDeletedRule);
.register(proformaCannotBeDeletedRule)
.register(proformaTransitionRule);

View File

@ -1,2 +1,2 @@
export * from "./issue-invoices.routes";
export * from "./issued-invoices.routes";
export * from "./proformas.routes";

View File

@ -6,18 +6,18 @@ import type { Sequelize } from "sequelize";
import {
GetIssueInvoiceByIdRequestSchema,
ListIssueInvoicesRequestSchema,
ListIssuedInvoicesRequestSchema,
ReportIssueInvoiceByIdRequestSchema,
} from "../../../common/dto";
import { buildCustomerInvoiceDependencies } from "../dependencies";
import {
GetIssueInvoiceController,
ListIssueInvoicesController,
ReportIssueInvoiceController,
ListIssuedInvoicesController,
ReportIssuedInvoiceController,
} from "./controllers";
export const issueInvoicesRouter = (params: ModuleParams) => {
export const issuedInvoicesRouter = (params: ModuleParams) => {
const { app, baseRoutePath, logger } = params as {
app: Application;
database: Sequelize;
@ -49,10 +49,10 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
router.get(
"/",
//checkTabContext,
validateRequest(ListIssueInvoicesRequestSchema, "params"),
validateRequest(ListIssuedInvoicesRequestSchema, "params"),
async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.list_issue_invoices();
const controller = new ListIssueInvoicesController(useCase /*, deps.presenters.list */);
const useCase = deps.useCases.list_issued_invoices();
const controller = new ListIssuedInvoicesController(useCase /*, deps.presenters.list */);
return controller.execute(req, res, next);
}
);
@ -62,7 +62,7 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(GetIssueInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.get_issue_invoice();
const useCase = deps.useCases.get_issued_invoice();
const controller = new GetIssueInvoiceController(useCase);
return controller.execute(req, res, next);
}
@ -73,11 +73,11 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(ReportIssueInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.report_issue_invoice();
const controller = new ReportIssueInvoiceController(useCase);
const useCase = deps.useCases.report_issued_invoice();
const controller = new ReportIssuedInvoiceController(useCase);
return controller.execute(req, res, next);
}
);
app.use(`${baseRoutePath}/issue-invoices`, router);
app.use(`${baseRoutePath}/issued-invoices`, router);
};

View File

@ -1,5 +1,9 @@
import { type RequestWithAuth, enforceTenant, enforceUser, mockUser } from "@erp/auth/api";
import { type ModuleParams, validateRequest } from "@erp/core/api";
import type { ILogger } from "@repo/rdx-logger";
import { type Application, type NextFunction, type Request, type Response, Router } from "express";
import type { Sequelize } from "sequelize";
import {
ChangeStatusProformaByIdParamsRequestSchema,
ChangeStatusProformaByIdRequestSchema,
@ -11,11 +15,7 @@ import {
ReportProformaByIdRequestSchema,
UpdateProformaByIdParamsRequestSchema,
UpdateProformaByIdRequestSchema,
} from "@erp/customer-invoices/common";
import type { ILogger } from "@repo/rdx-logger";
import { type Application, type NextFunction, type Request, type Response, Router } from "express";
import type { Sequelize } from "sequelize";
} from "../../../common";
import { buildCustomerInvoiceDependencies } from "../dependencies";
import {
@ -23,7 +23,7 @@ import {
CreateProformaController,
DeleteProformaController,
GetProformaController,
IssueProformaController,
IssueProformaController as IssuedProformaController,
ListProformasController,
ReportProformaController,
UpdateProformaController,
@ -150,7 +150,7 @@ export const proformasRouter = (params: ModuleParams) => {
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.issue_proforma();
const controller = new IssueProformaController(useCase);
const controller = new IssuedProformaController(useCase);
return controller.execute(req, res, next);
}
);

View File

@ -1,2 +1,2 @@
export * from "./issue-invoices";
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -1,3 +0,0 @@
export * from "./get-issue-invoice-by-id.request.dto";
export * from "./list-issue-invoices.request.dto";
export * from "./report-issue-invoice-by-id.request.dto";

View File

@ -1,5 +0,0 @@
import { CriteriaSchema } from "@erp/core";
import { z } from "zod/v4";
export const ListIssueInvoicesRequestSchema = CriteriaSchema;
export type ListIssueInvoicesRequestDTO = z.infer<typeof ListIssueInvoicesRequestSchema>;

View File

@ -0,0 +1,3 @@
export * from "./get-issued-invoice-by-id.request.dto";
export * from "./list-issued-invoices.request.dto";
export * from "./report-issued-invoice-by-id.request.dto";

View File

@ -0,0 +1,5 @@
import { CriteriaSchema } from "@erp/core";
import type { z } from "zod/v4";
export const ListIssuedInvoicesRequestSchema = CriteriaSchema;
export type ListIssuedInvoicesRequestDTO = z.infer<typeof ListIssuedInvoicesRequestSchema>;

View File

@ -1,2 +1,2 @@
export * from "./issue-invoices";
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -1,2 +0,0 @@
export * from "./get-issue-invoice-by-id.response.dto";
export * from "./list-issue-invoices.response.dto";

View File

@ -1,7 +1,7 @@
import { MetadataSchema, MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import { z } from "zod/v4";
export const GetIssueInvoiceByIdResponseSchema = z.object({
export const GetIssuedInvoiceByIdResponseSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
@ -79,4 +79,4 @@ export const GetIssueInvoiceByIdResponseSchema = z.object({
metadata: MetadataSchema.optional(),
});
export type GetIssueInvoiceByIdResponseDTO = z.infer<typeof GetIssueInvoiceByIdResponseSchema>;
export type GetIssuedInvoiceByIdResponseDTO = z.infer<typeof GetIssuedInvoiceByIdResponseSchema>;

View File

@ -0,0 +1,2 @@
export * from "./get-issued-invoice-by-id.response.dto";
export * from "./list-issued-invoices.response.dto";

View File

@ -6,7 +6,7 @@ import {
} from "@erp/core";
import { z } from "zod/v4";
export const ListIssueInvoicesResponseSchema = createPaginatedListSchema(
export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema(
z.object({
id: z.uuid(),
company_id: z.uuid(),
@ -51,4 +51,4 @@ export const ListIssueInvoicesResponseSchema = createPaginatedListSchema(
})
);
export type ListIssueInvoicesResponseDTO = z.infer<typeof ListIssueInvoicesResponseSchema>;
export type ListIssuedInvoicesResponseDTO = z.infer<typeof ListIssuedInvoicesResponseSchema>;

View File

@ -1,16 +1,20 @@
{
"common": {
"more_actions": "More actions",
"append_empty_row": "Append row",
"append_empty_row_tooltip": "Append a empty row",
"edit_row": "Edit",
"duplicate_row": "Duplicate",
"duplicate_selected_rows": "Duplicate",
"duplicate_selected_rows_tooltip": "Duplicate selected row(s)",
"remove_selected_rows": "Remove",
"remove_selected_rows_tooltip": "Remove selected row(s)",
"download_pdf": "Download PDF",
"send_email": "Send email",
"insert_row_above": "Insert row above",
"insert_row_below": "Insert row below",
"remove_row": "Remove",
"delete_row": "Delete",
"actions": "Actions",
"rows_selected": "{{count}} fila(s) seleccionadas.",
@ -22,12 +26,15 @@
},
"catalog": {
"status": {
"draft": "Draft",
"issued": "Issued",
"sent": "Sent",
"received": "Received",
"rejected": "Rejected"
"proformas": {
"status": {
"all": "All",
"draft": "Draft",
"sent": "Sent",
"approved": "Approved",
"rejected": "Rejected",
"issued": "Issued"
}
}
},
"pages": {
@ -40,9 +47,11 @@
"grid_columns": {
"invoice_number": "Inv. number",
"series": "Serie",
"reference": "Reference",
"status": "Status",
"invoice_date": "Proforma date",
"operation_date": "Operation date",
"recipient": "Customer",
"recipient_tin": "TIN",
"recipient_name": "Customer name",
"recipient_street": "Street",
@ -50,7 +59,10 @@
"recipient_province": "Province",
"recipient_postal_code": "Postal code",
"recipient_country": "Country",
"total_amount": "Total price"
"subtotal_amount": "Subtotal",
"discount_amount": "Discount",
"taxes_amount": "Taxes",
"total_amount": "Total"
}
},
"create": {

View File

@ -1,16 +1,20 @@
{
"common": {
"more_actions": "Más acciones",
"append_empty_row": "Añadir fila",
"append_empty_row_tooltip": "Añadir una fila vacía",
"edit_row": "Modificar",
"duplicate_row": "Duplicar",
"duplicate_selected_rows": "Duplicar",
"duplicate_selected_rows_tooltip": "Duplicar fila(s) seleccionada(s)",
"remove_selected_rows": "Eliminar",
"remove_selected_rows_tooltip": "Eliminar fila(s) seleccionada(s)",
"download_pdf": "Descargar en PDF",
"send_email": "Enviar por email",
"insert_row_above": "Insertar fila arriba",
"insert_row_below": "Insertar fila abajo",
"remove_row": "Eliminar",
"delete_row": "Eliminar",
"actions": "Acciones",
"rows_selected": "{{count}} fila(s) seleccionadas.",
@ -21,12 +25,15 @@
"clear": "Limpiar"
},
"catalog": {
"status": {
"draft": "Borrador",
"issued": "Emitida",
"sent": "Enviada",
"received": "Recibida",
"rejected": "Rechazada"
"proformas": {
"status": {
"all": "Todas",
"draft": "Borradores",
"sent": "Enviadas",
"approved": "Aprovadas",
"rejected": "Rechazadas",
"issued": "Emitidas"
}
}
},
"pages": {
@ -39,9 +46,11 @@
"grid_columns": {
"invoice_number": "Nº proforma",
"series": "Serie",
"reference": "Reference",
"status": "Estado",
"invoice_date": "Fecha de proforma",
"operation_date": "Fecha de operación",
"recipient": "Cliente",
"recipient_tin": "NIF/CIF",
"recipient_name": "Cliente",
"recipient_street": "Dirección",
@ -49,6 +58,9 @@
"recipient_province": "Provincia",
"recipient_postal_code": "Código postal",
"recipient_country": "País",
"subtotal_amount": "Subtotal",
"discount_amount": "Descuentos",
"taxes_amount": "Impuestos",
"total_amount": "Importe total"
}
},

View File

@ -1,7 +1,7 @@
import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/core";
import type {
GetIssueInvoiceByIdResponseDTO,
GetIssuedInvoiceByIdResponseDTO,
UpdateCustomerInvoiceByIdRequestDTO,
} from "../../common";
import type { InvoiceContextValue } from "../context";
@ -11,7 +11,7 @@ import type { InvoiceFormData } from "../schemas/invoice.form.schema";
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const invoiceDtoToFormAdapter = {
fromDto(dto: GetIssueInvoiceByIdResponseDTO, context: InvoiceContextValue): InvoiceFormData {
fromDto(dto: GetIssuedInvoiceByIdResponseDTO, context: InvoiceContextValue): InvoiceFormData {
const { taxCatalog } = context;
return {
invoice_number: dto.invoice_number,

View File

@ -1,2 +1 @@
@source "./components";
@source "./pages";
@source "**/*.{ts,tsx}";

View File

@ -1,2 +0,0 @@
export * from "./use-issue-invoice-query";
export * from "./use-issue-invoices-query";

View File

@ -1,23 +0,0 @@
import {
GetIssueInvoiceByIdResponseSchema,
ListIssueInvoicesResponseSchema,
} from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
// IssueInvoices
export const IssueInvoiceSchema = GetIssueInvoiceByIdResponseSchema.omit({
metadata: true,
});
export type IssueInvoice = z.infer<typeof IssueInvoiceSchema>;
export type IssueInvoiceRecipient = IssueInvoice["recipient"];
export type IssueInvoiceItem = ArrayElement<IssueInvoice["items"]>;
// Resultado de consulta con criteria (paginado, etc.)
export const IssueInvoiceSummaryPageSchema = ListIssueInvoicesResponseSchema.omit({
metadata: true,
});
export type IssueInvoiceSummaryPage = z.infer<typeof IssueInvoiceSummaryPageSchema>;
export type IssueInvoiceSummary = Omit<ArrayElement<IssueInvoiceSummaryPage["items"]>, "metadata">;

View File

@ -0,0 +1,2 @@
export * from "./use-issued-invoice-query";
export * from "./use-issued-invoices-query";

View File

@ -1,26 +1,26 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { IssueInvoice } from "../issue-invoice.api.schema";
import type { IssueInvoice } from "../issued-invoice.api.schema";
export const ISSUE_INVOICE_QUERY_KEY = (id: string): QueryKey => ["issue-invoice", id] as const;
export const ISSUED_INVOICE_QUERY_KEY = (id: string): QueryKey => ["issued-invoice", id] as const;
type InvoiceQueryOptions = {
enabled?: boolean;
};
export const useIssueInvoiceQuery = (issueInvoiceId?: string, options?: InvoiceQueryOptions) => {
export const useIssuedInvoiceQuery = (issuedInvoiceId?: string, options?: InvoiceQueryOptions) => {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!issueInvoiceId;
const enabled = (options?.enabled ?? true) && !!issuedInvoiceId;
return useQuery<IssueInvoice, DefaultError>({
queryKey: ISSUE_INVOICE_QUERY_KEY(issueInvoiceId ?? "unknown"),
queryKey: ISSUED_INVOICE_QUERY_KEY(issuedInvoiceId ?? "unknown"),
queryFn: async (context) => {
const { signal } = context;
if (!issueInvoiceId) {
if (!issueInvoiceId) throw new Error("issueInvoiceId is required");
if (!issuedInvoiceId) {
if (!issuedInvoiceId) throw new Error("issueInvoiceId is required");
}
return await dataSource.getOne<IssueInvoice>("issue-invoices", issueInvoiceId, {
return await dataSource.getOne<IssueInvoice>("issued-invoices", issuedInvoiceId, {
signal,
});
},

View File

@ -2,10 +2,10 @@ import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { IssueInvoiceSummaryPage } from "../issue-invoice.api.schema";
import type { IssuedInvoicesummaryPage } from "../issued-invoice.api.schema";
export const ISSUE_INVOICES_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
"issue-invoice",
export const ISSUED_INVOICES_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
"issued-invoice",
{
pageNumber: criteria.pageNumber ?? 0,
pageSize: criteria.pageSize ?? 10,
@ -16,21 +16,21 @@ export const ISSUE_INVOICES_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
},
];
type IssueInvoicesQueryOptions = {
type IssuedInvoicesQueryOptions = {
enabled?: boolean;
criteria?: CriteriaDTO;
};
// Obtener todas las facturas
export const useIssueInvoicesQuery = (options?: IssueInvoicesQueryOptions) => {
export const useIssuedInvoicesQuery = (options?: IssuedInvoicesQueryOptions) => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<IssueInvoiceSummaryPage, DefaultError>({
queryKey: ISSUE_INVOICES_QUERY_KEY(criteria),
return useQuery<IssuedInvoicesummaryPage, DefaultError>({
queryKey: ISSUED_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => {
return await dataSource.getList<IssueInvoiceSummaryPage>("issue-invoices", {
return await dataSource.getList<IssuedInvoicesummaryPage>("issued-invoices", {
signal,
...criteria,
});

View File

@ -1,16 +1,16 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type { IssueInvoiceSummaryPage } from "./issue-invoice.api.schema";
import type { IssuedInvoicesummaryPage } from "./issued-invoice.api.schema";
import type {
IssueInvoiceSummaryData,
IssueInvoiceSummaryPageData,
} from "./issue-invoice-resume.form.schema";
IssuedInvoicesummaryData,
IssuedInvoicesummaryPageData,
} from "./issued-invoice-resume.form.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const IssueInvoiceResumeDtoAdapter = {
fromDto(pageDto: IssueInvoiceSummaryPage, context?: unknown): IssueInvoiceSummaryPageData {
fromDto(pageDto: IssuedInvoicesummaryPage, context?: unknown): IssuedInvoicesummaryPageData {
return {
...pageDto,
items: pageDto.items.map(
@ -64,7 +64,7 @@ export const IssueInvoiceResumeDtoAdapter = {
),
//taxes: dto.taxes,
}) as unknown as IssueInvoiceSummaryData
}) as unknown as IssuedInvoicesummaryData
),
};
},

View File

@ -1,6 +1,6 @@
import type { IssueInvoiceSummary, IssueInvoiceSummaryPage } from "./issue-invoice.api.schema";
import type { IssuedInvoicesummary, IssuedInvoicesummaryPage } from "./issued-invoice.api.schema";
export type IssueInvoiceSummaryData = IssueInvoiceSummary & {
export type IssuedInvoicesummaryData = IssuedInvoicesummary & {
subtotal_amount_fmt: string;
subtotal_amount: number;
@ -20,6 +20,6 @@ export type IssueInvoiceSummaryData = IssueInvoiceSummary & {
total_amount: number;
};
export type IssueInvoiceSummaryPageData = IssueInvoiceSummaryPage & {
items: IssueInvoiceSummary[];
export type IssuedInvoicesummaryPageData = IssuedInvoicesummaryPage & {
items: IssuedInvoicesummary[];
};

View File

@ -0,0 +1,26 @@
import {
GetIssuedInvoiceByIdResponseSchema,
ListIssuedInvoicesResponseSchema,
} from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
// IssuedInvoices
export const IssuedInvoiceschema = GetIssuedInvoiceByIdResponseSchema.omit({
metadata: true,
});
export type IssueInvoice = z.infer<typeof IssuedInvoiceschema>;
export type IssueInvoiceRecipient = IssueInvoice["recipient"];
export type IssueInvoiceItem = ArrayElement<IssueInvoice["items"]>;
// Resultado de consulta con criteria (paginado, etc.)
export const IssuedInvoicesummaryPageSchema = ListIssuedInvoicesResponseSchema.omit({
metadata: true,
});
export type IssuedInvoicesummaryPage = z.infer<typeof IssuedInvoicesummaryPageSchema>;
export type IssuedInvoicesummary = Omit<
ArrayElement<IssuedInvoicesummaryPage["items"]>,
"metadata"
>;

View File

@ -5,7 +5,6 @@ import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from "../../hooks";
import { useTranslation } from "../../i18n";
import { useProformasGridColumns } from "../../proformas/pages/list/use-proformas-grid-columns";
import type { InvoiceSummaryFormData, InvoicesPageFormData } from "../../schemas";
export type InvoiceUpdateCompProps = {

View File

@ -10,8 +10,6 @@ import { invoiceResumeDtoToFormAdapter } from "../../adapters/invoice-resume-dto
import { useInvoicesQuery } from "../../hooks";
import { useTranslation } from "../../i18n";
import { InvoicesListGrid } from "./invoices-list-grid";
export const InvoiceListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@ -95,16 +93,7 @@ export const InvoiceListPage = () => {
<AppContent>
<div className="flex flex-col w-full h-full py-3">
<div className={"flex-1"}>
<InvoicesListGrid
invoicesPage={invoicesPageData}
loading={isLoading}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSearchChange={handleSearchChange}
pageIndex={pageIndex}
pageSize={pageSize}
searchValue={search}
/>
<>hola</>
</div>
</div>
</AppContent>

View File

@ -1 +1,2 @@
export * from "./use-proformas-grid-columns";
export * from "./use-proformas-list";

View File

@ -0,0 +1,443 @@
import { formatDate } from "@erp/core/client";
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Button,
ButtonGroup,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import {
CopyIcon,
DownloadIcon,
EditIcon,
MailIcon,
MoreVerticalIcon,
Trash2Icon,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "../../../../i18n";
import type { ProformaSummaryData } from "../../../schema";
import { ProformaStatusBadge } from "../ui";
type GridActionHandlers = {
onEdit?: (proforma: ProformaSummaryData) => void;
onDuplicate?: (proforma: ProformaSummaryData) => void;
onDownloadPdf?: (proforma: ProformaSummaryData) => void;
onSendEmail?: (proforma: ProformaSummaryData) => void;
onDelete?: (proforma: ProformaSummaryData) => void;
};
export function useProformasGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<ProformaSummaryData, unknown>[] {
const { t } = useTranslation();
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
return React.useMemo<ColumnDef<ProformaSummaryData>[]>(
() => [
// Nº
{
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_number")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-left text-primary">{row.original.invoice_number}</div>
),
enableHiding: false,
enableSorting: false,
size: 160,
minSize: 120,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_number"),
},
},
// Estado
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.status")}
/>
),
cell: ({ row }) => <ProformaStatusBadge status={row.original.status} />,
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.proformas.list.grid_columns.status"),
},
},
{
id: "recipient",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.recipient")}
/>
),
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
enableHiding: false,
size: 140,
minSize: 120,
cell: ({ row }) => {
const c = row.original.recipient;
return (
<div className="flex items-start gap-1 my-1.5">
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium truncate text-primary">{c.name}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
</div>
</div>
</div>
);
},
meta: {
title: t("pages.proformas.list.grid_columns.recipient"),
},
},
// Serie
{
accessorKey: "series",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.series")}
/>
),
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.series"),
},
},
// Referencia
{
accessorKey: "reference",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.reference")}
/>
),
cell: ({ row }) => <div className="font-medium text-left">{row.original.reference}</div>,
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.reference"),
},
},
// Fecha factura
{
accessorKey: "invoice_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.invoice_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.proformas.list.grid_columns.invoice_date"),
},
},
// Fecha operación
{
accessorKey: "operation_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.operation_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.operation_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.proformas.list.grid_columns.operation_date"),
},
},
// Subtotal amount
{
accessorKey: "subtotal_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.subtotal_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.subtotal_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.subtotal_amount"),
},
},
// Discount amount
{
accessorKey: "discount_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.discount_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">
{row.original.discount_amount_fmt}
</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.discount_amount"),
},
},
// Taxes amount
{
accessorKey: "taxes_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.taxes_amount")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-right tabular-nums">{row.original.taxes_amount_fmt}</div>
),
enableSorting: false,
size: 120,
minSize: 100,
meta: {
title: t("pages.proformas.list.grid_columns.taxes_amount"),
},
},
// Total amount
{
accessorKey: "total_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.total_amount")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-right tabular-nums">
{row.original.total_amount_fmt}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.proformas.list.grid_columns.total_amount"),
},
},
// ─────────────────────────────
// Acciones
// ─────────────────────────────
{
id: "actions",
header: () => <span className="sr-only">{t("common.actions")}</span>,
enableSorting: false,
enableHiding: false,
size: 110,
minSize: 96,
cell: ({ row }) => {
const proforma = row.original;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<ButtonGroup>
{/* Editar (acción primaria) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.edit_row")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onEdit?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<EditIcon aria-hidden="true" className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit_row")}</TooltipContent>
</Tooltip>
{/* Duplicar */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.duplicate_row")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onDuplicate?.(proforma);
}}
size="sm"
type="button"
variant="ghost"
>
<CopyIcon aria-hidden="true" className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_row")}</TooltipContent>
</Tooltip>
{/* Descargar en PDF */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.download_pdf")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onDownloadPdf?.(proforma);
}}
size="icon-sm"
type="button"
variant="ghost"
>
<DownloadIcon aria-hidden="true" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.download_pdf")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.delete_row")}
className="cursor-pointer text-destructive hover:bg-destructive/90 hover:text-white"
onClick={() => onDelete?.(proforma)}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2Icon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.delete_row")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.delete_row")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
{/** biome-ignore lint/suspicious/noSelfCompare: <Desactivado por ahora> */}
{false === false && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("common.more_actions")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDuplicate?.(proforma)}
>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate_row")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDownloadPdf?.(proforma)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onSendEmail?.(proforma)}
>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
onClick={() => onDelete?.(proforma)}
>
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
{t("common.delete_row")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</ButtonGroup>
);
},
meta: {
title: t("common.actions"),
},
},
],
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
);
}

View File

@ -11,19 +11,23 @@ export const useProformasList = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("all");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo<CriteriaDTO>(
() => ({
const criteria = useMemo<CriteriaDTO>(() => {
const baseFilters =
status !== "all" ? [{ field: "status", operator: "CONTAINS", value: status }] : [];
return {
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
order: "desc",
orderBy: "invoice_date",
}),
[pageSize, pageIndex, debouncedQ]
);
filters: baseFilters,
};
}, [pageSize, pageIndex, debouncedQ, status]);
const query = useProformasQuery({ criteria });
const data = useMemo(
@ -33,6 +37,8 @@ export const useProformasList = () => {
const setSearchValue = (value: string) => setSearch(value.trim().replace(/\s+/g, " "));
const setStatusFilter = (newStatus: string) => setStatus(newStatus);
return {
...query,
data,
@ -42,5 +48,6 @@ export const useProformasList = () => {
setPageIndex,
setPageSize,
setSearchValue,
setStatusFilter,
};
};

View File

@ -51,6 +51,7 @@ export const ProformaListPage = () => {
onPageChange={list.setPageIndex}
onPageSizeChange={list.setPageSize}
onSearchChange={list.setSearchValue}
onStatusFilterChange={list.setStatusFilter}
pageIndex={list.pageIndex}
pageSize={list.pageSize}
searchValue={list.search}

View File

@ -1,114 +0,0 @@
import type { CriteriaDTO } from "@erp/core";
import { PageHeader } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useProformasQuery } from "../../hooks";
import { ProformaSummaryDtoAdapter } from "../../../adapters/proforma-summary-dto.adapter";
import { ProformasGrid } from "./proformas-grid";
export const ProformaListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo(
() =>
({
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
order: "desc",
orderBy: "invoice_date",
}) as CriteriaDTO,
[pageSize, pageIndex, debouncedQ]
);
const { data, isLoading, isError, error } = useProformasQuery({
criteria,
});
const proformaPageData = useMemo(() => {
if (!data) return undefined;
return ProformaSummaryDtoAdapter.fromDto(data);
}, [data]);
const handlePageChange = (newPageIndex: number) => {
setPageIndex(newPageIndex);
};
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setPageIndex(0);
};
const handleSearchChange = (value: string) => {
// Normalización ligera: recorta y colapsa espacios internos
const cleaned = value.trim().replace(/\s+/g, " ");
setSearch(cleaned);
setPageIndex(0);
};
if (isError || !proformaPageData) {
return (
<AppContent>
<ErrorAlert
message={(error as Error)?.message || "Error al cargar el listado"}
title={t("pages.proformas.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<div className="flex items-center space-x-2">
<Button
aria-label={t("pages.proformas.create.title")}
className="cursor-pointer"
onClick={() => navigate("/proformas/create")}
variant={"default"}
>
<PlusIcon aria-hidden className="mr-2 h-4 w-4" />
{t("pages.proformas.create.title")}
</Button>
</div>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
<div className="flex flex-col w-full h-full py-3">
<div className={"flex-1"}>
<ProformasGrid
data={proformaPageData}
loading={isLoading}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSearchChange={handleSearchChange}
pageIndex={pageIndex}
pageSize={pageSize}
searchValue={search}
/>
</div>
</div>
</AppContent>
</>
);
};

View File

@ -1,196 +0,0 @@
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FileDownIcon, FilterIcon } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from "../../../hooks";
import { useTranslation } from "../../../i18n";
import type { InvoiceSummaryFormData } from "../../../schemas";
import type { ProformaSummaryPageData } from "../../schema/proforma-summary.web.schema";
import { useProformasGridColumns } from "./use-proformas-grid-columns";
export type ProformaGridProps = {
data: ProformaSummaryPageData;
loading?: boolean;
pageIndex: number;
pageSize: number;
onPageChange?: (pageNumber: number) => void;
onPageSizeChange?: (pageSize: number) => void;
searchValue: string;
onSearchChange: (value: string) => void;
onRowClick?: (
row: ProformaSummaryPageData,
index: number,
event: React.MouseEvent<HTMLTableRowElement>
) => void;
};
// Create new GridExample component
export const ProformasGrid = ({
data,
loading,
pageIndex,
pageSize,
onPageChange,
onPageSizeChange,
searchValue,
onSearchChange,
onRowClick,
}: ProformaGridProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { items, total_items } = data;
// Hook con Sheet de shadcn
const preview = usePinnedPreviewSheet<InvoiceSummaryFormData>({
persistKey: "invoice-preview-pin",
widthClass: "w-[500px]",
});
const [statusFilter, setStatusFilter] = useState("todas");
const columns = useProformasGridColumns({
onEdit: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
onDuplicate: (proforma) => null, //duplicateInvoice(inv.id),
onDownloadPdf: (proforma) => null, //downloadInvoicePdf(inv.id),
onSendEmail: (proforma) => null, //sendInvoiceEmail(inv.id),
onDelete: (proforma) => null, //confirmDelete(inv.id),
});
// Navegación accesible (click o teclado)
/*const goToRow = useCallback(
(id: string, newTab = false) => {
const url = `/customer-invoices/${id}/edit`;
newTab ? window.open(url, "_blank", "noopener,noreferrer") : navigate(url);
},
[navigate]
);*/
/*const onRowClicked = useCallback(
(e: RowClickedEvent<any>) => {
if (!e.data) return;
const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey);
goToRow(e.data.id, newTab);
},
[goToRow]
);
const onCellKeyDown = useCallback(
(e: CellKeyDownEvent<any>) => {
if (!e.data) return;
const ev = e.event;
if (!(ev && ev instanceof KeyboardEvent)) return;
const key = ev.key;
if (key === "Enter" || key === " ") {
ev.preventDefault();
goToRow(e.data.id);
}
if ((ev.ctrlKey || ev.metaKey) && key === "Enter") {
ev.preventDefault();
goToRow(e.data.id, true);
}
},
[goToRow]
);
const handleRowClick = useCallback(
(invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => {
const url = `/customer-invoices/${invoice.id}/edit`;
if (e.metaKey || e.ctrlKey) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
preview.open(invoice);
},
[preview]
);*/
if (loading) {
return (
<div className="flex flex-col gap-4">
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
</div>
);
}
// Render principal
return (
<div className="flex flex-col gap-4">
{/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
<Select onValueChange={setStatusFilter} value={statusFilter}>
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
<FilterIcon className="mr-2 h-4 w-4" />
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas</SelectItem>
<SelectItem value="pagada">Borradores</SelectItem>
<SelectItem value="pendiente">Enviadas</SelectItem>
<SelectItem value="vencida">Aprobadas</SelectItem>
<SelectItem value="vencida">Rechazadas</SelectItem>
<SelectItem value="vencida">Emitidas</SelectItem>
</SelectContent>
</Select>
<Button
className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent"
variant="outline"
>
<FileDownIcon className="mr-2 h-4 w-4" />
Exportar
</Button>
</div>
<div className="relative flex">
<div className={preview.isPinned ? "flex-1 mr-[500px]" : "flex-1"}>
<DataTable
columns={columns}
data={items}
enablePagination
enableRowSelection
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={handleRowClick}
pageIndex={pageIndex}
pageSize={pageSize}
readOnly
totalItems={total_items}
/>
</div>
{/*<preview.Preview>
{({ item, isPinned, close, togglePin }) => (
<InvoicePreviewPanel
invoice={item}
isPinned={isPinned}
onClose={close}
onTogglePin={togglePin}
/>
)}
</preview.Preview>*/}
</div>
</div>
);
};

View File

@ -1 +1,2 @@
export * from "./proforma-status-badge";
export * from "./proformas-grid";

View File

@ -0,0 +1,68 @@
import { Badge } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { forwardRef } from "react";
import { useTranslation } from "../../../../i18n";
export type ProformaStatus = "draft" | "sent" | "approved" | "rejected" | "issued";
export type ProformaStatusBadgeProps = {
status: string | ProformaStatus; // permitir cualquier valor
dotVisible?: boolean;
className?: string;
};
const statusColorConfig: Record<ProformaStatus, { badge: string; dot: string }> = {
draft: {
badge:
"bg-gray-500/10 dark:bg-gray-500/20 hover:bg-gray-500/10 text-gray-600 border-gray-400/60",
dot: "bg-gray-500",
},
sent: {
badge:
"bg-amber-500/10 dark:bg-amber-500/20 hover:bg-amber-500/10 text-amber-500 border-amber-600/60",
dot: "bg-amber-500",
},
approved: {
badge:
"bg-emerald-500/10 dark:bg-emerald-500/20 hover:bg-emerald-500/10 text-emerald-500 border-emerald-600/60",
dot: "bg-emerald-500",
},
rejected: {
badge: "bg-red-500/10 dark:bg-red-500/20 hover:bg-red-500/10 text-red-500 border-red-600/60",
dot: "bg-red-500",
},
issued: {
badge:
"bg-blue-600/10 dark:bg-blue-600/20 hover:bg-blue-600/10 text-blue-500 border-blue-600/60",
dot: "bg-blue-500",
},
};
export const ProformaStatusBadge = forwardRef<HTMLDivElement, ProformaStatusBadgeProps>(
({ status, dotVisible, className, ...props }, ref) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as ProformaStatus;
const config = statusColorConfig[normalizedStatus];
const commonClassName =
"transition-colors duration-200 cursor-pointer shadow-none rounded-full";
if (!config) {
return (
<Badge className={cn(commonClassName, className)} ref={ref} {...props}>
{status}
</Badge>
);
}
return (
<Badge className={cn(commonClassName, config.badge, className)} {...props}>
{dotVisible && <div className={cn("h-1.5 w-1.5 rounded-full mr-2", config.dot)} />}
{t(`catalog.proformas.status.${normalizedStatus}`, { defaultValue: status })}
</Badge>
);
}
);
ProformaStatusBadge.displayName = "ProformaStatusBadge";

View File

@ -1,18 +1,18 @@
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FileDownIcon, FilterIcon } from "lucide-react";
import { FilterIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import type { ProformaSummaryPageData } from "../../../schema/proforma-summary.web.schema";
import { useProformasGridColumns } from "../use-proformas-grid-columns";
import { useProformasGridColumns } from "../hooks";
interface ProformasGridProps {
data: ProformaSummaryPageData;
@ -25,6 +25,7 @@ interface ProformasGridProps {
onPageSizeChange: (s: number) => void;
onRowClick?: (id: string) => void;
onExportClick?: () => void;
onStatusFilterChange?: (newStatus: string) => void;
}
export const ProformasGrid = ({
@ -38,10 +39,19 @@ export const ProformasGrid = ({
onPageSizeChange,
onRowClick,
onExportClick,
onStatusFilterChange,
}: ProformasGridProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const { items, total_items } = data;
const columns = useProformasGridColumns();
const columns = useProformasGridColumns({
onEdit: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
onDuplicate: (proforma) => null, //duplicateInvoice(inv.id),
onDownloadPdf: (proforma) => null, //downloadInvoicePdf(inv.id),
onSendEmail: (proforma) => null, //sendInvoiceEmail(inv.id),
onDelete: (proforma) => null, //confirmDelete(inv.id),
});
if (loading)
return (
@ -55,33 +65,37 @@ export const ProformasGrid = ({
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<SimpleSearchInput onSearchChange={onSearchChange} value={searchValue} />
<Select defaultValue="all">
<div className="flex flex-col sm:flex-row gap-4">
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
<Select defaultValue="all" onValueChange={onStatusFilterChange}>
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.all")}</SelectItem>
<SelectItem value="draft">{t("filters.draft")}</SelectItem>
<SelectItem value="sent">{t("filters.sent")}</SelectItem>
<SelectItem value="all">{t("catalog.proformas.status.all")}</SelectItem>
<SelectItem value="draft">{t("catalog.proformas.status.draft")}</SelectItem>
<SelectItem value="sent">{t("catalog.proformas.status.sent")}</SelectItem>
<SelectItem value="approved">{t("catalog.proformas.status.approved")}</SelectItem>
<SelectItem value="rejected">{t("catalog.proformas.status.rejected")}</SelectItem>
<SelectItem value="issued">{t("catalog.proformas.status.issued")}</SelectItem>
</SelectContent>
</Select>
<Button onClick={onExportClick} variant="outline">
<FileDownIcon aria-hidden className="mr-2 size-4" />
{t("actions.export")}
</Button>
</div>
<DataTable
columns={columns}
columnVisibility={{
subtotal_amount_fmt: false,
discount_amount_fmt: false,
taxes_amount_fmt: false,
}}
data={items}
enablePagination
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={(_, row) => onRowClick?.(row.id)}
onRowClick={(row, _index) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}

View File

@ -1,314 +0,0 @@
import { formatDate } from "@erp/core/client";
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Avatar,
AvatarFallback,
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import {
Building2Icon,
CopyIcon,
DownloadIcon,
EditIcon,
MailIcon,
MoreVerticalIcon,
Trash2Icon,
User2Icon,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "../../../i18n";
import type { InvoiceSummaryFormData } from "../../../schemas";
import { CustomerInvoiceStatusBadge } from "../../../shared/ui/components";
type GridActionHandlers = {
onEdit?: (invoice: InvoiceSummaryFormData) => void;
onDuplicate?: (invoice: InvoiceSummaryFormData) => void;
onDownloadPdf?: (invoice: InvoiceSummaryFormData) => void;
onSendEmail?: (invoice: InvoiceSummaryFormData) => void;
onDelete?: (invoice: InvoiceSummaryFormData) => void;
};
function initials(name: string) {
const parts = name.trim().split(/\s+/).slice(0, 2);
return parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "?";
}
const KindBadge = ({ isCompany }: { isCompany: boolean }) => (
<Badge className="gap-1 tracking-wide text-xs text-foreground/70" variant="outline">
{isCompany ? <Building2Icon className="size-3.5" /> : <User2Icon className="size-3.5" />}
{isCompany ? "Company" : "Person"}
</Badge>
);
const Soft = ({ children }: { children: React.ReactNode }) => (
<span className="text-muted-foreground">{children}</span>
);
export function useProformasGridColumns(
actionHandlers: GridActionHandlers = {}
): ColumnDef<InvoiceSummaryFormData>[] {
const { t } = useTranslation();
const { onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete } = actionHandlers;
return React.useMemo<ColumnDef<InvoiceSummaryFormData>[]>(
() => [
// Nº
{
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_number")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-left text-primary">{row.original.invoice_number}</div>
),
enableHiding: false,
enableSorting: false,
size: 160,
minSize: 120,
},
// Estado
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.status")}
/>
),
cell: ({ row }) => <CustomerInvoiceStatusBadge status={row.original.status} />,
enableSorting: false,
size: 140,
minSize: 120,
},
{
id: "recipient",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.list.grid_columns.recipient")}
/>
),
accessorFn: (row) => row.recipient.name, // para ordenar/buscar por nombre
enableHiding: false,
size: 140,
minSize: 120,
cell: ({ row }) => {
const c = row.original.recipient;
const isCompany = String(c.is_company).toLowerCase() === "true";
return (
<div className="flex items-start gap-1 my-1.5">
<Avatar className="size-10 hidden">
<AvatarFallback aria-label={c.name}>{initials(c.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium truncate text-primary">{c.name}</span>
{c.trade_name && <Soft>({c.trade_name})</Soft>}
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
<KindBadge isCompany={isCompany} />
</div>
</div>
</div>
);
},
},
// Serie
{
accessorKey: "series",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.series")}
/>
),
cell: ({ row }) => <div className="font-normal text-left">{row.original.series}</div>,
enableSorting: false,
size: 120,
minSize: 100,
},
// Referencia
{
accessorKey: "reference",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.proformas.list.grid_columns.reference")}
/>
),
cell: ({ row }) => <div className="font-medium text-left">{row.original.reference}</div>,
enableSorting: false,
size: 120,
minSize: 100,
},
// Fecha factura
{
accessorKey: "invoice_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.invoice_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.invoice_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
},
// Fecha operación
{
accessorKey: "operation_date",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.operation_date")}
/>
),
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{formatDate(row.original.operation_date)}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
},
// Total
{
accessorKey: "total_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums"
column={column}
title={t("pages.proformas.list.grid_columns.total_amount")}
/>
),
cell: ({ row }) => (
<div className="font-semibold text-right tabular-nums">
{row.original.total_amount_fmt}
</div>
),
enableSorting: false,
size: 140,
minSize: 120,
},
// ─────────────────────────────
// Acciones
// ─────────────────────────────
{
id: "actions",
header: () => <span className="sr-only">{t("common.actions")}</span>,
enableSorting: false,
enableHiding: false,
size: 110,
minSize: 96,
cell: ({ row }) => {
const invoice = row.original;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<div className="flex items-center justify-end gap-1 pr-1">
{/* Editar (acción primaria) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.edit")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onEdit?.(invoice);
}}
size="sm"
type="button"
variant="ghost"
>
<EditIcon aria-hidden="true" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t("common.more_actions")}
className="cursor-pointer text-muted-foreground hover:text-primary"
onClick={stop}
size="sm"
type="button"
variant="ghost"
>
<MoreVerticalIcon aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDuplicate?.(invoice)}
>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDownloadPdf?.(invoice)}
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onSendEmail?.(invoice)}
>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem>{" "}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
onClick={() => onDelete?.(invoice)}
>
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
],
[t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]
);
}

View File

@ -13,7 +13,7 @@ export type ProformaSummaryData = ProformaSummary & {
taxable_amount_fmt: string;
taxable_amount: number;
taxes_amoun_fmt: string;
taxes_amount_fmt: string;
taxes_amount: number;
total_amount_fmt: string;
@ -21,5 +21,5 @@ export type ProformaSummaryData = ProformaSummary & {
};
export type ProformaSummaryPageData = ProformaSummaryPage & {
items: ProformaSummary[];
items: ProformaSummaryData[];
};

View File

@ -1,7 +1,7 @@
import {
CreateProformaRequestSchema,
GetProformaByIdResponseSchema,
ListProformasResponseSchema,
type ListProformasResponseDTO,
UpdateProformaByIdRequestSchema,
} from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
@ -23,9 +23,5 @@ export type CreateProformaInput = z.infer<typeof CreateProformaSchema>; // Cuerp
export type UpdateProformaInput = z.infer<typeof UpdateProformaSchema>; // Cuerpo para actualizar
// Resultado de consulta con criteria (paginado, etc.)
export const ProformaSummaryPageSchema = ListProformasResponseSchema.omit({
metadata: true,
});
export type ProformaSummaryPage = z.infer<typeof ProformaSummaryPageSchema>;
export type ProformaSummaryPage = Omit<ListProformasResponseDTO, "metadata">;
export type ProformaSummary = Omit<ArrayElement<ProformaSummaryPage["items"]>, "metadata">;

View File

@ -1,18 +1,18 @@
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
import { GetIssueInvoiceByIdResponseSchema, ListIssueInvoicesResponseSchema } from "../../common";
import { GetIssuedInvoiceByIdResponseSchema, ListIssuedInvoicesResponseSchema } from "../../common";
export const IssueInvoiceSchema = GetIssueInvoiceByIdResponseSchema.omit({
export const IssuedInvoiceschema = GetIssuedInvoiceByIdResponseSchema.omit({
metadata: true,
});
export type IssueInvoice = z.infer<typeof IssueInvoiceSchema>;
export type IssueInvoice = z.infer<typeof IssuedInvoiceschema>;
export type IssueInvoiceRecipient = IssueInvoice["recipient"];
export type IssueInvoiceItem = ArrayElement<IssueInvoice["items"]>;
// Resultado de consulta con criteria (paginado, etc.)
export const IssueInvoicesPageSchema = ListIssueInvoicesResponseSchema.omit({
export const IssuedInvoicesPageSchema = ListIssuedInvoicesResponseSchema.omit({
metadata: true,
});

View File

@ -1,67 +0,0 @@
import { Badge } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { forwardRef } from "react";
import { useTranslation } from "../../../i18n";
export type CustomerInvoiceStatus = "draft" | "sent" | "approved" | "rejected" | "issued";
export type CustomerInvoiceStatusBadgeProps = {
status: string | CustomerInvoiceStatus; // permitir cualquier valor
dotVisible?: boolean;
className?: string;
};
const statusColorConfig: Record<CustomerInvoiceStatus, { badge: string; dot: string }> = {
draft: {
badge:
"bg-gray-500/10 dark:bg-gray-500/20 hover:bg-gray-500/10 text-gray-600 border-gray-400/60",
dot: "bg-gray-500",
},
sent: {
badge:
"bg-amber-500/10 dark:bg-amber-500/20 hover:bg-amber-500/10 text-amber-500 border-amber-600/60",
dot: "bg-amber-500",
},
approved: {
badge:
"bg-emerald-500/10 dark:bg-emerald-500/20 hover:bg-emerald-500/10 text-emerald-500 border-emerald-600/60",
dot: "bg-emerald-500",
},
rejected: {
badge: "bg-red-500/10 dark:bg-red-500/20 hover:bg-red-500/10 text-red-500 border-red-600/60",
dot: "bg-red-500",
},
issued: {
badge:
"bg-blue-600/10 dark:bg-blue-600/20 hover:bg-blue-600/10 text-blue-500 border-blue-600/60",
dot: "bg-blue-500",
},
};
export const CustomerInvoiceStatusBadge = forwardRef<
HTMLDivElement,
CustomerInvoiceStatusBadgeProps
>(({ status, dotVisible, className, ...props }, ref) => {
const { t } = useTranslation();
const normalizedStatus = status.toLowerCase() as CustomerInvoiceStatus;
const config = statusColorConfig[normalizedStatus];
const commonClassName = "transition-colors duration-200 cursor-pointer shadow-none rounded-full";
if (!config) {
return (
<Badge className={cn(commonClassName, className)} ref={ref} {...props}>
{status}
</Badge>
);
}
return (
<Badge className={cn(commonClassName, config.badge, className)} {...props}>
{dotVisible && <div className={cn("h-1.5 w-1.5 rounded-full mr-2", config.dot)} />}
{t(`catalog.status.${normalizedStatus}`, { defaultValue: status })}
</Badge>
);
});
CustomerInvoiceStatusBadge.displayName = "CustomerInvoiceStatusBadge";

View File

@ -1,6 +1,7 @@
export * from "../../../proformas/pages/list/ui/proforma-status-badge";
export * from "./customer-invoice-editor-skeleton";
export * from "./customer-invoice-prices-card";
export * from "./customer-invoice-status-badge";
export * from "./customer-invoice-taxes-multi-select";
export * from "./editor";
export * from "./editor/invoice-tax-summary";

View File

@ -28,6 +28,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src", "../core/src/common/helpers/date-helper.ts"],
"include": ["src"],
"exclude": ["node_modules"]
}